mirror of
https://github.com/aitingtingya/mv-obcc.git
synced 2026-07-22 07:47:28 +00:00
release: 0.7.2
This commit is contained in:
parent
a4a03b4988
commit
1cfa3e6f43
8 changed files with 1520 additions and 4 deletions
20
README.md
20
README.md
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
**mv-SenceAI** 是一款专为 Obsidian 打造的 AI 笔记、科研、与终端桥接插件。它能够在您的本地代码环境、命令行工具与 Obsidian 知识库之间建立无缝的数据通道与操作体验。
|
||||
|
||||
本插件包含五个相对独立的核心能力:
|
||||
本插件包含六个相对独立的核心能力:
|
||||
1. **IDE 桥接 (IDE Bridge)**:为 Claude Code 与 Codex CLI 提供 Obsidian 当前的上下文信息(如当前标签、选区内容)。Claude Code 侧支持标准 MCP 主动工具和差异审核(Diff);Codex CLI 侧支持 `/ide` 上下文读取,并通过标准 MCP 使用 Obsidian 工具。
|
||||
2. **划词助手 (LLM Assistant)**:完全独立于 IDE 桥接的内置功能。允许您在 Obsidian 的各种视图(Markdown、PDF、Web Viewer)中选中文本后,通过自定义提示词直接流式调用 OpenAI 或 Anthropic 兼容的语言模型 API。
|
||||
3. **行内补全 (Inline Completion)**:在 Markdown 编辑器中显示 ghost text 续写建议,支持接受、取消、拒绝后重新生成,并可在左侧功能区一键启用/停用。
|
||||
4. **系统终端 (System Terminal)**:在 Obsidian 内部拉起全功能的本地系统终端(支持 macOS/Linux Shell 与 Windows ConPTY),支持与 Obsidian 双向联动,双击或 Ctrl+点击终端内文件路径可直接在编辑器中定位笔记。
|
||||
5. **源码编写辅助 (Source Assist)**:可将用户指定的非 `.md` 后缀注册为 Markdown view,按后缀分别配置 Latex Suite 风格 snippets,并提供新建非 MD 源码文件、源码高亮与可选 TeX 增强渲染。
|
||||
6. **默认文件打开器 (Default File Opener)**:可选地把 `.md` 及已启用的源码后缀注册到系统默认打开方式,通过本地 wrapper 唤醒指定 vault 并在 Obsidian 中打开 vault 外文件。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -106,6 +107,12 @@
|
|||
- **屏蔽 Markdown inline 视觉污染**:除 `.tex` 外,自定义源码后缀会屏蔽常见 Markdown inline 视觉格式(如 `==高亮==`、`**加粗**`、`*斜体*`、`~~删除线~~`),减少源码显示污染;snippets 仍依赖 Markdown/CodeMirror 编辑器宿主运行。
|
||||
- **兼容性提醒**:将非 `.md` 后缀注册为 Markdown view 时,如果该后缀已被 Obsidian 或其它插件注册为其它 view,本插件会尝试解除原注册并改为 Markdown view;这可能影响其它插件对同后缀文件的打开方式。TeX 增强渲染也可能影响光标移动、折叠或其它编辑器插件兼容性,建议按需开启。
|
||||
|
||||
### 6. 🗂️ 默认文件打开器
|
||||
- **手动启用**:在设置页“默认文件打开器”分区开启功能后,可选择“仅支持 md”或“支持扩展后缀名”。扩展后缀来自已启用的源码编写辅助 profile。
|
||||
- **检查、注入、清理**:检查按钮会区分“尚未由本插件注册”“已由其它 vault 注册”“当前 vault 已注册”。注入按钮不会覆盖已有本插件注册;如需切换 vault,请先清理再注入。
|
||||
- **vault 外文件访问**:该功能会读取您双击打开的 vault 外本地文件,并在当前 vault 的镜像目录创建 symlink,使 Obsidian 的 Markdown editor 可以编辑该文件。启用后插件会写入 `~/.mv-senceai/` 下的 wrapper 状态,并按系统平台修改默认打开方式。
|
||||
- **启动边界**:wrapper 只用于把系统文件打开请求转发给本地 Obsidian 插件服务。插件不会安装常驻后台守护进程;Obsidian 关闭时,wrapper 会先通过 Obsidian URL 唤醒目标 vault,再等待插件本地服务启动。
|
||||
|
||||
---
|
||||
|
||||
## Windows 系统 PTY 依赖手动安装指南
|
||||
|
|
@ -136,6 +143,7 @@
|
|||
> - **配置的隔离性**:划词助手、行内补全或系统终端的 API 调用/配置错误,**绝对不会**波及或影响 Claude Code / Codex CLI 桥接通道的稳定性。
|
||||
> - **桌面权限说明**:Claude Code 集成会读取和更新 Claude 项目配置与 IDE lock 文件;Codex 集成会创建本地 IPC socket,并在 `~/.codex/config.toml` 中维护本插件的 MCP 服务地址,均不会启动外部进程后台守护服务。
|
||||
> - **源码后缀兼容性**:源码编写辅助会将用户配置的非 `.md` 后缀注册为 Markdown view;若同后缀已由其它插件处理,可能改变该后缀文件的打开方式。
|
||||
> - **默认打开器权限**:默认文件打开器会访问 vault 外的本地文件,并在系统层注册 `.md` 或扩展后缀的默认打开方式。多 vault 同时注入会产生归属冲突,请用设置页的检查和清理按钮显式管理当前注册。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -152,12 +160,13 @@
|
|||
|
||||
**mv-SenceAI** is a desktop bridge and system terminal plugin connecting your local vaults, CLI tools, and development environment to Obsidian.
|
||||
|
||||
This plugin provides five key capabilities:
|
||||
This plugin provides six key capabilities:
|
||||
1. **IDE Bridge**: Feeds contextual information (active tab, selections) from your vault to Claude Code and Codex CLI. Claude Code uses the existing IDE/MCP bridge; Codex CLI uses `/ide` context IPC plus standard MCP tools.
|
||||
2. **LLM Assistant (Selection Reader)**: A completely independent feature to call OpenAI or Anthropic compatible APIs directly from Obsidian views (Markdown, PDF, Web Viewer) using custom prompt templates, streaming responses into a floating output window.
|
||||
3. **Inline Completion**: A separate Markdown-only ghost-text completion module with accept, cancel, and reject/regenerate shortcuts, controllable via a ribbon toggle button.
|
||||
4. **System Terminal**: Spawns fully functional local system terminals (macOS/Linux Shell & Windows ConPTY) inside Obsidian, supporting automatic dark/light theme sync, customized fonts, and file path click-to-open integration.
|
||||
5. **Source Assist**: Registers configured non-`.md` extensions as Markdown views, routes Latex Suite-style snippets by file extension, and provides non-MD file creation, source highlighting, and optional TeX enhanced rendering.
|
||||
6. **Default File Opener**: Optionally registers `.md` and enabled source extensions as system file handlers, then opens external local files in the selected vault through a local wrapper.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -256,6 +265,12 @@ You can install this plugin either **via the Community Plugin Store**, **manuall
|
|||
- **Markdown inline visual suppression**: Except for `.tex`, custom source extensions suppress common Markdown inline visual formatting such as `==highlight==`, `**bold**`, `*italic*`, and `~~strikethrough~~` to reduce source display pollution; snippets still rely on the Markdown/CodeMirror editor host.
|
||||
- **Compatibility note**: When a non-`.md` extension is registered as a Markdown view, mv-SenceAI may unregister an existing handler for the same extension and replace it with Markdown view handling. This can change how other plugins open files with that extension. TeX enhanced rendering is a custom Live Preview extension and may affect cursor movement, folding, or editor-plugin compatibility.
|
||||
|
||||
### 6. 🗂️ Default File Opener
|
||||
- **Manual opt-in**: Enable "Default File Opener" in settings, then choose either Markdown-only handling or Markdown plus enabled Source Assist extensions.
|
||||
- **Check, install, cleanup**: The check button distinguishes unregistered, registered by another vault, and registered by the current vault. Install never overwrites an existing mv-SenceAI registration; use cleanup first when switching vault ownership.
|
||||
- **External file access**: This feature reads local files outside the vault when you open them from the operating system. It creates symlinks in the configured vault mirror folder so Obsidian's Markdown editor can edit those files, writes wrapper state under `~/.mv-senceai/`, and changes system file-handler registration for the selected extensions.
|
||||
- **Startup boundary**: The wrapper only forwards file-open requests to the local plugin service. It does not install a persistent background daemon; when Obsidian is closed, the wrapper wakes the target vault through an Obsidian URL and waits for the plugin service to start.
|
||||
|
||||
---
|
||||
|
||||
## Windows Manual PTY Dependency Installation Guide
|
||||
|
|
@ -286,6 +301,7 @@ Spawning terminals on Windows relies on Python and the `pywinpty` package. If th
|
|||
> - **Config Isolation**: LLM Assistant, Inline Completion, and System Terminal configurations are fully isolated and **will not** interfere with Claude Code or Codex CLI IDE bridges.
|
||||
> - **Permissions**: Integrated Claude Code and Codex CLI bridges manage project lock files, settings, and local Unix domain socket IPC; they do not start persistent background daemon processes.
|
||||
> - **Source Extension Compatibility**: Source Assist registers configured non-`.md` extensions as Markdown views. If another plugin already handles the same extension, its file-opening behavior may change.
|
||||
> - **Default Opener Permissions**: The Default File Opener accesses local files outside the vault and registers `.md` or configured source extensions as system file handlers. Multiple vaults cannot own the same mv-SenceAI registration at once; manage ownership explicitly with the settings check and cleanup buttons.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
259
main.ts
259
main.ts
|
|
@ -6,8 +6,10 @@ import {
|
|||
FuzzySuggestModal,
|
||||
Keymap,
|
||||
loadPrism,
|
||||
Modal,
|
||||
Notice,
|
||||
Plugin,
|
||||
Setting,
|
||||
type App,
|
||||
type PaneType,
|
||||
type TFolder,
|
||||
|
|
@ -105,6 +107,15 @@ 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,
|
||||
|
|
@ -126,6 +137,13 @@ import type {
|
|||
|
||||
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> {
|
||||
|
|
@ -156,6 +174,63 @@ class CustomMarkdownExtensionModal extends FuzzySuggestModal<string> {
|
|||
}
|
||||
}
|
||||
|
||||
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) =>
|
||||
|
|
@ -169,6 +244,7 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
port = 0;
|
||||
mcpStatus = "尚未检查";
|
||||
codexMcpStatus = "Codex MCP 未启用";
|
||||
defaultFileOpenerStatus = "尚未检查";
|
||||
claudeIdeError: string | null = null;
|
||||
codexIdeError: string | null = null;
|
||||
private server: BridgeServer | null = null;
|
||||
|
|
@ -186,6 +262,8 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
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;
|
||||
|
|
@ -232,7 +310,20 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
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 !==
|
||||
|
|
@ -258,6 +349,12 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
);
|
||||
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),
|
||||
|
|
@ -367,11 +464,28 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
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();
|
||||
|
||||
await this.syncLocalServices(false, false);
|
||||
this.schedulePostLayoutStartup();
|
||||
this.terminalTracker.scan();
|
||||
this.selectionHighlighter.sync(true);
|
||||
|
|
@ -572,6 +686,81 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -785,8 +974,58 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
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)
|
||||
);
|
||||
|
|
@ -817,6 +1056,7 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
? "Codex MCP 等待启动后初始化"
|
||||
: "Codex MCP 未启用";
|
||||
}
|
||||
this.syncExternalFileOpenerRuntime();
|
||||
}
|
||||
|
||||
private async syncClaudeIntegration(notify = false): Promise<void> {
|
||||
|
|
@ -855,6 +1095,20 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
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) =>
|
||||
|
|
@ -871,6 +1125,7 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
|
||||
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();
|
||||
|
|
@ -1145,6 +1400,8 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
this.cleanupCodexRuntimeCacheBestEffort();
|
||||
await this.syncCodexIdeProvider();
|
||||
if (this.unloaded) return;
|
||||
await this.syncLocalServices(false, false);
|
||||
if (this.unloaded) return;
|
||||
this.scheduleCodexMcpRegistrationIfReady();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ interface BridgeServerOptions {
|
|||
vaultRoot: string;
|
||||
settings: () => BridgeSettings;
|
||||
upstreamBaseUrl: () => string;
|
||||
externalFileOpenerToken?: () => string;
|
||||
onExternalFileOpen?: (
|
||||
request: { path: string; makeFrontmost: boolean },
|
||||
context: BridgeClientContext,
|
||||
) => Promise<unknown>;
|
||||
onMessage: (
|
||||
request: JsonRpcRequest,
|
||||
context: BridgeClientContext,
|
||||
|
|
@ -219,6 +224,10 @@ export class BridgeServer {
|
|||
await this.handleMcpHttp(request, response);
|
||||
return;
|
||||
}
|
||||
if (pathname === "/external-file/open") {
|
||||
await this.handleExternalFileOpenHttp(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = this.options.settings();
|
||||
const upstreamBaseUrl = this.options.upstreamBaseUrl().trim();
|
||||
|
|
@ -281,6 +290,91 @@ export class BridgeServer {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleExternalFileOpenHttp(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
): Promise<void> {
|
||||
if (!this.options.onExternalFileOpen || !this.options.externalFileOpenerToken) {
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "External file opener is disabled." }));
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405, {
|
||||
allow: "POST",
|
||||
"content-type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify({ error: "Method not allowed" }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.headers.authorization !==
|
||||
`Bearer ${this.options.externalFileOpenerToken()}`
|
||||
) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.from(chunk);
|
||||
size += buffer.length;
|
||||
if (size > 64 * 1024) {
|
||||
response.writeHead(413, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Request too large" }));
|
||||
return;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
response.writeHead(400, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Parse error" }));
|
||||
return;
|
||||
}
|
||||
const pathValue =
|
||||
body && typeof body === "object" && "path" in body
|
||||
? (body as { path?: unknown }).path
|
||||
: undefined;
|
||||
if (typeof pathValue !== "string" || pathValue.trim() === "") {
|
||||
response.writeHead(400, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Missing path." }));
|
||||
return;
|
||||
}
|
||||
const makeFrontmost =
|
||||
body && typeof body === "object" && "makeFrontmost" in body
|
||||
? (body as { makeFrontmost?: unknown }).makeFrontmost !== false
|
||||
: true;
|
||||
|
||||
try {
|
||||
const result = await this.options.onExternalFileOpen(
|
||||
{ path: pathValue, makeFrontmost },
|
||||
{ clientId: randomUUID(), channel: "ide" },
|
||||
);
|
||||
const failed =
|
||||
result !== null &&
|
||||
typeof result === "object" &&
|
||||
"success" in result &&
|
||||
(result as { success?: unknown }).success === false;
|
||||
response.writeHead(failed ? 422 : 200, {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(result));
|
||||
} catch (error) {
|
||||
response.writeHead(500, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : "Internal error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMcpHttp(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
|
|
|
|||
|
|
@ -170,6 +170,13 @@ export const DEFAULT_SETTINGS = {
|
|||
highlightThemeId: "builtin:obsidian",
|
||||
customHighlightThemes: [],
|
||||
},
|
||||
externalFileOpener: {
|
||||
enabled: false,
|
||||
extensionMode: "markdown-only" as const,
|
||||
mirrorFolder: "senceai-external-files/mirror",
|
||||
mappings: {},
|
||||
openerToken: "",
|
||||
},
|
||||
mcpEnabled: true,
|
||||
mcpAuthToken: "",
|
||||
claudeExecutable: "",
|
||||
|
|
|
|||
660
src/external-file-opener-system.ts
Normal file
660
src/external-file-opener-system.ts
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
import childProcess from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
ExternalFileOpenerExtensionMode,
|
||||
} from "./types";
|
||||
|
||||
const execFile = promisify(childProcess.execFile);
|
||||
|
||||
export const EXTERNAL_FILE_OPENER_MARKER = "mv-senceai-file-opener-v1";
|
||||
export const EXTERNAL_FILE_OPENER_BUNDLE_ID = "com.mv.senceai.file-opener";
|
||||
export const EXTERNAL_FILE_OPENER_PROG_ID = "MV.SenceAI.FileOpener";
|
||||
|
||||
export type DefaultOpenerStatusKind =
|
||||
| "not-default"
|
||||
| "other-vault"
|
||||
| "current-vault";
|
||||
|
||||
export interface ExternalFileOpenerOwner {
|
||||
marker: typeof EXTERNAL_FILE_OPENER_MARKER;
|
||||
vaultRoot: string;
|
||||
vaultName: string;
|
||||
extensionMode: ExternalFileOpenerExtensionMode;
|
||||
extensions: string[];
|
||||
installedAt: number;
|
||||
platform: NodeJS.Platform;
|
||||
appPath?: string;
|
||||
commandPath?: string;
|
||||
}
|
||||
|
||||
export interface ExternalFileOpenerRuntime {
|
||||
marker: typeof EXTERNAL_FILE_OPENER_MARKER;
|
||||
vaultRoot: string;
|
||||
vaultName: string;
|
||||
port: number;
|
||||
token: string;
|
||||
pid: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DefaultOpenerStatus {
|
||||
kind: DefaultOpenerStatusKind;
|
||||
message: string;
|
||||
owner: ExternalFileOpenerOwner | null;
|
||||
}
|
||||
|
||||
export interface DefaultOpenerOperationResult {
|
||||
ok: boolean;
|
||||
status: DefaultOpenerStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InstallExternalFileOpenerOptions {
|
||||
vaultRoot: string;
|
||||
vaultName: string;
|
||||
extensionMode: ExternalFileOpenerExtensionMode;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeExternalFileOpenerOptions {
|
||||
vaultRoot: string;
|
||||
vaultName: string;
|
||||
port: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function externalFileOpenerStateDirectory(): string {
|
||||
return path.join(os.homedir(), ".mv-senceai");
|
||||
}
|
||||
|
||||
export function externalFileOpenerOwnerPath(): string {
|
||||
return path.join(externalFileOpenerStateDirectory(), "file-opener-owner.json");
|
||||
}
|
||||
|
||||
export function externalFileOpenerRuntimePath(): string {
|
||||
return path.join(externalFileOpenerStateDirectory(), "file-opener-runtime.json");
|
||||
}
|
||||
|
||||
export function sameVaultRoot(a: string, b: string): boolean {
|
||||
const left = path.resolve(a);
|
||||
const right = path.resolve(b);
|
||||
return process.platform === "win32"
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right;
|
||||
}
|
||||
|
||||
export function defaultOpenerStatusFromOwner(
|
||||
owner: ExternalFileOpenerOwner | null,
|
||||
currentVaultRoot: string,
|
||||
): DefaultOpenerStatus {
|
||||
if (!owner) {
|
||||
return {
|
||||
kind: "not-default",
|
||||
message: "SenceAI 不是系统默认打开器。",
|
||||
owner: null,
|
||||
};
|
||||
}
|
||||
if (sameVaultRoot(owner.vaultRoot, currentVaultRoot)) {
|
||||
return {
|
||||
kind: "current-vault",
|
||||
message: "SenceAI 的本仓库是系统默认打开器。",
|
||||
owner,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "other-vault",
|
||||
message: `SenceAI 是系统默认打开器,但 owner 是:${owner.vaultRoot}`,
|
||||
owner,
|
||||
};
|
||||
}
|
||||
|
||||
function readJson<T>(filePath: string): T | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const temporary = `${filePath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(temporary, filePath);
|
||||
}
|
||||
|
||||
function ownerAppBundleIsUsable(owner: ExternalFileOpenerOwner): boolean {
|
||||
if (owner.platform !== "darwin") return true;
|
||||
const appPath = owner.appPath || macAppPath();
|
||||
const executablePath = path.join(appPath, "Contents", "MacOS", "droplet");
|
||||
try {
|
||||
fs.accessSync(executablePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readExternalFileOpenerOwner(): ExternalFileOpenerOwner | null {
|
||||
const owner = readJson<Partial<ExternalFileOpenerOwner>>(
|
||||
externalFileOpenerOwnerPath(),
|
||||
);
|
||||
return owner?.marker === EXTERNAL_FILE_OPENER_MARKER &&
|
||||
typeof owner.vaultRoot === "string" &&
|
||||
typeof owner.vaultName === "string"
|
||||
? (owner as ExternalFileOpenerOwner)
|
||||
: null;
|
||||
}
|
||||
|
||||
function macAppPath(): string {
|
||||
return path.join(
|
||||
externalFileOpenerStateDirectory(),
|
||||
"MV SenceAI File Opener.app",
|
||||
);
|
||||
}
|
||||
|
||||
function windowsCommandPath(): string {
|
||||
return path.join(externalFileOpenerStateDirectory(), "mv-senceai-file-opener.cmd");
|
||||
}
|
||||
|
||||
function linuxCommandPath(): string {
|
||||
return path.join(externalFileOpenerStateDirectory(), "mv-senceai-file-opener");
|
||||
}
|
||||
|
||||
function linuxDesktopPath(): string {
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
".local",
|
||||
"share",
|
||||
"applications",
|
||||
"mv-senceai-file-opener.desktop",
|
||||
);
|
||||
}
|
||||
|
||||
function macShellWrapper(): string {
|
||||
return `#!/bin/zsh
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
/usr/bin/osascript -l JavaScript "$DIR/mv-senceai-file-opener.jxa" "$@"
|
||||
`;
|
||||
}
|
||||
|
||||
function macJxaWrapper(): string {
|
||||
return String.raw`ObjC.import("Foundation");
|
||||
|
||||
function readJson(filePath) {
|
||||
const text = $.NSString.stringWithContentsOfFileEncodingError(
|
||||
filePath,
|
||||
$.NSUTF8StringEncoding,
|
||||
null
|
||||
);
|
||||
if (!text) return null;
|
||||
return JSON.parse(ObjC.unwrap(text));
|
||||
}
|
||||
|
||||
function runTask(launchPath, args) {
|
||||
const task = $.NSTask.alloc.init;
|
||||
const pipe = $.NSPipe.pipe;
|
||||
task.launchPath = launchPath;
|
||||
task.arguments = args;
|
||||
task.standardOutput = pipe;
|
||||
task.standardError = $.NSPipe.pipe;
|
||||
task.launch;
|
||||
task.waitUntilExit;
|
||||
const data = pipe.fileHandleForReading.readDataToEndOfFile;
|
||||
const text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding);
|
||||
return { status: task.terminationStatus, stdout: text ? ObjC.unwrap(text) : "" };
|
||||
}
|
||||
|
||||
function postFile(filePath, runtime) {
|
||||
const payload = JSON.stringify({ path: filePath, makeFrontmost: true });
|
||||
const result = runTask("/usr/bin/curl", [
|
||||
"-sS",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"-X",
|
||||
"POST",
|
||||
"http://127.0.0.1:" + runtime.port + "/external-file/open",
|
||||
"-H",
|
||||
"Authorization: Bearer " + runtime.token,
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"--data-binary",
|
||||
payload,
|
||||
]);
|
||||
return result.stdout.trim() === "200";
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const stateDir = ObjC.unwrap($.NSHomeDirectory()) + "/.mv-senceai";
|
||||
const owner = readJson(stateDir + "/file-opener-owner.json");
|
||||
if (!owner) return 2;
|
||||
for (const filePath of argv) {
|
||||
let opened = false;
|
||||
for (let attempt = 0; attempt < 30 && !opened; attempt++) {
|
||||
const runtime = readJson(stateDir + "/file-opener-runtime.json");
|
||||
if (runtime && runtime.vaultRoot === owner.vaultRoot && postFile(filePath, runtime)) {
|
||||
opened = true;
|
||||
break;
|
||||
}
|
||||
runTask("/usr/bin/open", [
|
||||
"obsidian://open?vault=" + encodeURIComponent(owner.vaultName),
|
||||
]);
|
||||
delay(0.5);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function macInfoPlist(extensions: string[]): string {
|
||||
const extensionItems = extensions.map((extension) => ` <string>${extension}</string>`).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${EXTERNAL_FILE_OPENER_BUNDLE_ID}</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>MV SenceAI File Opener</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>droplet</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>aplt</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Markdown and SenceAI source files</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
${extensionItems}
|
||||
</array>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function macAppletScript(): string {
|
||||
return String.raw`ObjC.import("Foundation");
|
||||
|
||||
function readJson(filePath) {
|
||||
const text = $.NSString.stringWithContentsOfFileEncodingError(
|
||||
filePath,
|
||||
$.NSUTF8StringEncoding,
|
||||
null
|
||||
);
|
||||
if (!text) return null;
|
||||
return JSON.parse(ObjC.unwrap(text));
|
||||
}
|
||||
|
||||
function runTask(launchPath, args) {
|
||||
const task = $.NSTask.alloc.init;
|
||||
const pipe = $.NSPipe.pipe;
|
||||
task.launchPath = launchPath;
|
||||
task.arguments = args;
|
||||
task.standardOutput = pipe;
|
||||
task.standardError = $.NSPipe.pipe;
|
||||
task.launch;
|
||||
task.waitUntilExit;
|
||||
const data = pipe.fileHandleForReading.readDataToEndOfFile;
|
||||
const text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding);
|
||||
return { status: task.terminationStatus, stdout: text ? ObjC.unwrap(text) : "" };
|
||||
}
|
||||
|
||||
function postFile(filePath, runtime) {
|
||||
const payload = JSON.stringify({ path: filePath, makeFrontmost: true });
|
||||
const result = runTask("/usr/bin/curl", [
|
||||
"-sS",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"-X",
|
||||
"POST",
|
||||
"http://127.0.0.1:" + runtime.port + "/external-file/open",
|
||||
"-H",
|
||||
"Authorization: Bearer " + runtime.token,
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"--data-binary",
|
||||
payload,
|
||||
]);
|
||||
return result.stdout.trim() === "200";
|
||||
}
|
||||
|
||||
function handlePaths(paths) {
|
||||
const stateDir = ObjC.unwrap($.NSHomeDirectory()) + "/.mv-senceai";
|
||||
const owner = readJson(stateDir + "/file-opener-owner.json");
|
||||
if (!owner) return 2;
|
||||
for (const filePath of paths) {
|
||||
let opened = false;
|
||||
for (let attempt = 0; attempt < 30 && !opened; attempt++) {
|
||||
const runtime = readJson(stateDir + "/file-opener-runtime.json");
|
||||
if (runtime && runtime.vaultRoot === owner.vaultRoot && postFile(filePath, runtime)) {
|
||||
opened = true;
|
||||
break;
|
||||
}
|
||||
runTask("/usr/bin/open", [
|
||||
"obsidian://open?vault=" + encodeURIComponent(owner.vaultName),
|
||||
]);
|
||||
$.NSThread.sleepForTimeInterval(0.5);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
return handlePaths(argv || []);
|
||||
}
|
||||
|
||||
function openDocuments(docs) {
|
||||
const paths = [];
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
paths.push(String(docs[i]));
|
||||
}
|
||||
return handlePaths(paths);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function installMacOpener(owner: ExternalFileOpenerOwner): Promise<void> {
|
||||
const appPath = macAppPath();
|
||||
fs.rmSync(appPath, { recursive: true, force: true });
|
||||
fs.mkdirSync(externalFileOpenerStateDirectory(), { recursive: true });
|
||||
const sourcePath = path.join(
|
||||
externalFileOpenerStateDirectory(),
|
||||
"mv-senceai-file-opener.jxa",
|
||||
);
|
||||
fs.writeFileSync(sourcePath, macAppletScript(), "utf8");
|
||||
await execFile("/usr/bin/osacompile", ["-l", "JavaScript", "-o", appPath, sourcePath]);
|
||||
fs.rmSync(sourcePath, { force: true });
|
||||
|
||||
const macOsPath = path.join(appPath, "Contents", "MacOS");
|
||||
const infoPath = path.join(appPath, "Contents", "Info.plist");
|
||||
fs.mkdirSync(macOsPath, { recursive: true });
|
||||
fs.writeFileSync(infoPath, macInfoPlist(owner.extensions), "utf8");
|
||||
const executablePath = path.join(macOsPath, "mv-senceai-file-opener");
|
||||
fs.writeFileSync(executablePath, macShellWrapper(), { mode: 0o755 });
|
||||
fs.writeFileSync(
|
||||
path.join(macOsPath, "mv-senceai-file-opener.jxa"),
|
||||
macJxaWrapper(),
|
||||
"utf8",
|
||||
);
|
||||
owner.appPath = appPath;
|
||||
|
||||
const lsregister =
|
||||
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
||||
await execFile(lsregister, ["-f", appPath]);
|
||||
const script = `ObjC.import("CoreServices");
|
||||
const bundle = $("${EXTERNAL_FILE_OPENER_BUNDLE_ID}");
|
||||
const extensions = ${JSON.stringify(owner.extensions)};
|
||||
for (const ext of extensions) {
|
||||
const uti = $.UTTypeCreatePreferredIdentifierForTag($.kUTTagClassFilenameExtension, $(ext), null);
|
||||
if (uti) $.LSSetDefaultRoleHandlerForContentType(uti, $.kLSRolesAll, bundle);
|
||||
}`;
|
||||
await execFile("/usr/bin/osascript", ["-l", "JavaScript", "-e", script]);
|
||||
}
|
||||
|
||||
async function cleanupMacOpener(owner: ExternalFileOpenerOwner | null): Promise<void> {
|
||||
const appPath = owner?.appPath || macAppPath();
|
||||
fs.rmSync(appPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function windowsPowerShellWrapper(): string {
|
||||
return String.raw`param([string]$FilePath)
|
||||
$StateDir = Join-Path $HOME ".mv-senceai"
|
||||
$OwnerPath = Join-Path $StateDir "file-opener-owner.json"
|
||||
$RuntimePath = Join-Path $StateDir "file-opener-runtime.json"
|
||||
if (!(Test-Path $OwnerPath)) { exit 2 }
|
||||
$Owner = Get-Content $OwnerPath -Raw | ConvertFrom-Json
|
||||
for ($i = 0; $i -lt 30; $i++) {
|
||||
if (Test-Path $RuntimePath) {
|
||||
$Runtime = Get-Content $RuntimePath -Raw | ConvertFrom-Json
|
||||
if ($Runtime.vaultRoot -eq $Owner.vaultRoot) {
|
||||
try {
|
||||
Invoke-RestMethod -Method Post -Uri ("http://127.0.0.1:{0}/external-file/open" -f $Runtime.port) -Headers @{ Authorization = ("Bearer " + $Runtime.token) } -ContentType "application/json" -Body (@{ path = $FilePath; makeFrontmost = $true } | ConvertTo-Json -Compress) | Out-Null
|
||||
exit 0
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
Start-Process ("obsidian://open?vault=" + [uri]::EscapeDataString($Owner.vaultName))
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
exit 1
|
||||
`;
|
||||
}
|
||||
|
||||
async function installWindowsOpener(owner: ExternalFileOpenerOwner): Promise<void> {
|
||||
const stateDir = externalFileOpenerStateDirectory();
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const ps1Path = path.join(stateDir, "mv-senceai-file-opener.ps1");
|
||||
const cmdPath = windowsCommandPath();
|
||||
fs.writeFileSync(ps1Path, windowsPowerShellWrapper(), "utf8");
|
||||
fs.writeFileSync(
|
||||
cmdPath,
|
||||
`@echo off\r\npowershell.exe -NoProfile -ExecutionPolicy Bypass -File "${ps1Path}" "%~1"\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
owner.commandPath = cmdPath;
|
||||
await execFile("reg", [
|
||||
"add",
|
||||
`HKCU\\Software\\Classes\\${EXTERNAL_FILE_OPENER_PROG_ID}\\shell\\open\\command`,
|
||||
"/ve",
|
||||
"/d",
|
||||
`"${cmdPath}" "%1"`,
|
||||
"/f",
|
||||
]);
|
||||
for (const extension of owner.extensions) {
|
||||
await execFile("reg", [
|
||||
"add",
|
||||
`HKCU\\Software\\Classes\\.${extension}`,
|
||||
"/ve",
|
||||
"/d",
|
||||
EXTERNAL_FILE_OPENER_PROG_ID,
|
||||
"/f",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupWindowsOpener(owner: ExternalFileOpenerOwner | null): Promise<void> {
|
||||
for (const extension of owner?.extensions ?? ["md", "markdown"]) {
|
||||
await execFile("reg", [
|
||||
"delete",
|
||||
`HKCU\\Software\\Classes\\.${extension}`,
|
||||
"/ve",
|
||||
"/f",
|
||||
]).catch(() => undefined);
|
||||
}
|
||||
await execFile("reg", [
|
||||
"delete",
|
||||
`HKCU\\Software\\Classes\\${EXTERNAL_FILE_OPENER_PROG_ID}`,
|
||||
"/f",
|
||||
]).catch(() => undefined);
|
||||
}
|
||||
|
||||
function linuxShellWrapper(): string {
|
||||
return `#!/bin/sh
|
||||
python3 - "$1" <<'PY'
|
||||
import json, os, subprocess, sys, time, urllib.parse, urllib.request
|
||||
state_dir = os.path.join(os.path.expanduser("~"), ".mv-senceai")
|
||||
owner_path = os.path.join(state_dir, "file-opener-owner.json")
|
||||
runtime_path = os.path.join(state_dir, "file-opener-runtime.json")
|
||||
with open(owner_path, "r", encoding="utf-8") as fh:
|
||||
owner = json.load(fh)
|
||||
file_path = sys.argv[1]
|
||||
for _ in range(30):
|
||||
try:
|
||||
with open(runtime_path, "r", encoding="utf-8") as fh:
|
||||
runtime = json.load(fh)
|
||||
if runtime.get("vaultRoot") == owner.get("vaultRoot"):
|
||||
payload = json.dumps({"path": file_path, "makeFrontmost": True}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{runtime['port']}/external-file/open",
|
||||
data=payload,
|
||||
headers={"Authorization": "Bearer " + runtime["token"], "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=1).read()
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
subprocess.run(["xdg-open", "obsidian://open?vault=" + urllib.parse.quote(owner["vaultName"])], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
time.sleep(0.5)
|
||||
sys.exit(1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
async function installLinuxOpener(owner: ExternalFileOpenerOwner): Promise<void> {
|
||||
const commandPath = linuxCommandPath();
|
||||
fs.writeFileSync(commandPath, linuxShellWrapper(), { mode: 0o755 });
|
||||
owner.commandPath = commandPath;
|
||||
const desktopPath = linuxDesktopPath();
|
||||
fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
desktopPath,
|
||||
`[Desktop Entry]
|
||||
Name=MV SenceAI File Opener
|
||||
Exec=${commandPath} %f
|
||||
Type=Application
|
||||
Terminal=false
|
||||
MimeType=text/markdown;text/x-markdown;
|
||||
NoDisplay=true
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await execFile("xdg-mime", [
|
||||
"default",
|
||||
path.basename(desktopPath),
|
||||
"text/markdown",
|
||||
]).catch(() => undefined);
|
||||
await execFile("xdg-mime", [
|
||||
"default",
|
||||
path.basename(desktopPath),
|
||||
"text/x-markdown",
|
||||
]).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function cleanupLinuxOpener(owner: ExternalFileOpenerOwner | null): Promise<void> {
|
||||
fs.rmSync(owner?.commandPath || linuxCommandPath(), { force: true });
|
||||
fs.rmSync(linuxDesktopPath(), { force: true });
|
||||
}
|
||||
|
||||
async function installPlatformOpener(owner: ExternalFileOpenerOwner): Promise<void> {
|
||||
if (process.platform === "darwin") {
|
||||
await installMacOpener(owner);
|
||||
} else if (process.platform === "win32") {
|
||||
await installWindowsOpener(owner);
|
||||
} else if (process.platform === "linux") {
|
||||
await installLinuxOpener(owner);
|
||||
} else {
|
||||
throw new Error(`暂不支持当前平台:${process.platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupPlatformOpener(owner: ExternalFileOpenerOwner | null): Promise<void> {
|
||||
if (process.platform === "darwin") {
|
||||
await cleanupMacOpener(owner);
|
||||
} else if (process.platform === "win32") {
|
||||
await cleanupWindowsOpener(owner);
|
||||
} else if (process.platform === "linux") {
|
||||
await cleanupLinuxOpener(owner);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExternalFileOpenerSystem {
|
||||
check(currentVaultRoot: string): DefaultOpenerStatus {
|
||||
const owner = readExternalFileOpenerOwner();
|
||||
if (owner && !ownerAppBundleIsUsable(owner)) {
|
||||
return {
|
||||
kind: "not-default",
|
||||
message: "SenceAI 默认打开器记录存在,但 app 不可启动;请先清理再重新注入。",
|
||||
owner,
|
||||
};
|
||||
}
|
||||
return defaultOpenerStatusFromOwner(owner, currentVaultRoot);
|
||||
}
|
||||
|
||||
async install(
|
||||
options: InstallExternalFileOpenerOptions,
|
||||
): Promise<DefaultOpenerOperationResult> {
|
||||
const existing = readExternalFileOpenerOwner();
|
||||
if (existing) {
|
||||
const status = defaultOpenerStatusFromOwner(existing, options.vaultRoot);
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
message: `${status.message} 如需更换 owner,请先清理默认打开方式。`,
|
||||
};
|
||||
}
|
||||
|
||||
const owner: ExternalFileOpenerOwner = {
|
||||
marker: EXTERNAL_FILE_OPENER_MARKER,
|
||||
vaultRoot: options.vaultRoot,
|
||||
vaultName: options.vaultName,
|
||||
extensionMode: options.extensionMode,
|
||||
extensions: options.extensions,
|
||||
installedAt: Date.now(),
|
||||
platform: process.platform,
|
||||
};
|
||||
await installPlatformOpener(owner);
|
||||
writeJson(externalFileOpenerOwnerPath(), owner);
|
||||
const status = defaultOpenerStatusFromOwner(owner, options.vaultRoot);
|
||||
return {
|
||||
ok: true,
|
||||
status,
|
||||
message: "已注入 SenceAI 默认打开器。",
|
||||
};
|
||||
}
|
||||
|
||||
async cleanup(currentVaultRoot: string): Promise<DefaultOpenerOperationResult> {
|
||||
const owner = readExternalFileOpenerOwner();
|
||||
await cleanupPlatformOpener(owner);
|
||||
fs.rmSync(externalFileOpenerOwnerPath(), { force: true });
|
||||
const status = defaultOpenerStatusFromOwner(null, currentVaultRoot);
|
||||
return {
|
||||
ok: true,
|
||||
status,
|
||||
message: owner
|
||||
? "已清理 SenceAI 默认打开器。"
|
||||
: "没有发现 SenceAI 默认打开器记录。",
|
||||
};
|
||||
}
|
||||
|
||||
writeRuntime(options: RuntimeExternalFileOpenerOptions): void {
|
||||
const runtime: ExternalFileOpenerRuntime = {
|
||||
marker: EXTERNAL_FILE_OPENER_MARKER,
|
||||
vaultRoot: options.vaultRoot,
|
||||
vaultName: options.vaultName,
|
||||
port: options.port,
|
||||
token: options.token,
|
||||
pid: process.pid,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
writeJson(externalFileOpenerRuntimePath(), runtime);
|
||||
}
|
||||
|
||||
removeRuntime(vaultRoot: string): void {
|
||||
const runtime = readJson<Partial<ExternalFileOpenerRuntime>>(
|
||||
externalFileOpenerRuntimePath(),
|
||||
);
|
||||
if (!runtime?.vaultRoot || !sameVaultRoot(runtime.vaultRoot, vaultRoot)) return;
|
||||
fs.rmSync(externalFileOpenerRuntimePath(), { force: true });
|
||||
}
|
||||
}
|
||||
370
src/external-file-opener.ts
Normal file
370
src/external-file-opener.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import crypto from "node:crypto";
|
||||
import childProcess from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { TFile, type App, type WorkspaceLeaf } from "obsidian";
|
||||
import {
|
||||
normalizeSourceAssistExtension,
|
||||
} from "./source-assist/source-assist-settings";
|
||||
import type {
|
||||
BridgeSettings,
|
||||
ExternalFileMapping,
|
||||
ExternalFileOpenerExtensionMode,
|
||||
} from "./types";
|
||||
|
||||
export interface ExternalFileOpenResult {
|
||||
success: boolean;
|
||||
externalPath: string;
|
||||
vaultPath: string | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ExternalFileOpenerOptions {
|
||||
app: App;
|
||||
getSettings: () => BridgeSettings;
|
||||
getVaultRoot: () => string;
|
||||
saveSettings: () => Promise<void>;
|
||||
focusObsidianApp?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const MARKDOWN_EXTERNAL_EXTENSIONS = ["md", "markdown"] as const;
|
||||
const execFile = promisify(childProcess.execFile);
|
||||
|
||||
export function normalizeExternalFileOpenerExtensionMode(
|
||||
value: unknown,
|
||||
): ExternalFileOpenerExtensionMode {
|
||||
return value === "markdown-and-source-assist"
|
||||
? "markdown-and-source-assist"
|
||||
: "markdown-only";
|
||||
}
|
||||
|
||||
export function externalFileAllowedExtensions(
|
||||
settings: Pick<BridgeSettings, "externalFileOpener" | "sourceAssist">,
|
||||
): string[] {
|
||||
const extensions = new Set<string>(MARKDOWN_EXTERNAL_EXTENSIONS);
|
||||
if (settings.externalFileOpener.extensionMode === "markdown-and-source-assist") {
|
||||
for (const profile of settings.sourceAssist.profiles) {
|
||||
if (!profile.enabled) continue;
|
||||
const extension = normalizeSourceAssistExtension(profile.extension);
|
||||
if (extension && extension !== "md" && extension !== "markdown") {
|
||||
extensions.add(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(extensions);
|
||||
}
|
||||
|
||||
export function normalizeExternalFileExtension(filePath: string): string {
|
||||
return path
|
||||
.extname(filePath.replace(/\\/g, "/"))
|
||||
.replace(/^\./, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isExternalFileExtensionAllowed(
|
||||
settings: Pick<BridgeSettings, "externalFileOpener" | "sourceAssist">,
|
||||
filePath: string,
|
||||
): boolean {
|
||||
const extension = normalizeExternalFileExtension(filePath);
|
||||
return extension !== "" && externalFileAllowedExtensions(settings).includes(extension);
|
||||
}
|
||||
|
||||
export function isAbsoluteExternalPath(filePath: string): boolean {
|
||||
return (
|
||||
path.isAbsolute(filePath) ||
|
||||
/^[a-zA-Z]:[\\/]/.test(filePath) ||
|
||||
/^\\\\[^\\]+\\[^\\]+/.test(filePath)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeExternalFilePath(filePath: string): string {
|
||||
const trimmed = filePath.trim();
|
||||
if (!trimmed) throw new Error("外部文件路径为空。");
|
||||
const resolved = trimmed.startsWith("file://") ? fileURLToPath(trimmed) : trimmed;
|
||||
if (!isAbsoluteExternalPath(resolved)) {
|
||||
throw new Error("外部文件路径必须是绝对路径。");
|
||||
}
|
||||
return process.platform === "win32"
|
||||
? path.win32.normalize(resolved)
|
||||
: path.normalize(resolved);
|
||||
}
|
||||
|
||||
function normalizeVaultPath(vaultPath: string): string {
|
||||
return vaultPath
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
function safeBasename(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
const name = normalized.split("/").filter(Boolean).pop() || "external.md";
|
||||
return name.replace(/[<>:"|?*\x00-\x1F]/g, "_");
|
||||
}
|
||||
|
||||
export function externalFileMirrorPath(
|
||||
mirrorFolder: string,
|
||||
externalPath: string,
|
||||
): string {
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(externalPath)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
return normalizeVaultPath(`${mirrorFolder}/${hash}/${safeBasename(externalPath)}`);
|
||||
}
|
||||
|
||||
function comparePaths(a: string, b: string): boolean {
|
||||
const left = path.resolve(a);
|
||||
const right = path.resolve(b);
|
||||
return process.platform === "win32"
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right;
|
||||
}
|
||||
|
||||
interface ElectronWindowLike {
|
||||
focus?: () => void;
|
||||
show?: () => void;
|
||||
restore?: () => void;
|
||||
isMinimized?: () => boolean;
|
||||
}
|
||||
|
||||
interface ElectronLike {
|
||||
remote?: {
|
||||
app?: { focus?: (options?: { steal?: boolean }) => void };
|
||||
getCurrentWindow?: () => ElectronWindowLike;
|
||||
};
|
||||
}
|
||||
|
||||
function rendererWindow(): (Window & { require?: (moduleName: string) => unknown }) | null {
|
||||
const globals = globalThis as unknown as {
|
||||
activeWindow?: Window & { require?: (moduleName: string) => unknown };
|
||||
window?: Window & { require?: (moduleName: string) => unknown };
|
||||
};
|
||||
return globals.activeWindow ?? globals.window ?? null;
|
||||
}
|
||||
|
||||
function focusElectronWindow(): boolean {
|
||||
let focused = false;
|
||||
try {
|
||||
const electron = rendererWindow()?.require?.("electron") as ElectronLike | undefined;
|
||||
const currentWindow = electron?.remote?.getCurrentWindow?.();
|
||||
if (currentWindow?.isMinimized?.()) {
|
||||
currentWindow.restore?.();
|
||||
focused = true;
|
||||
}
|
||||
currentWindow?.show?.();
|
||||
currentWindow?.focus?.();
|
||||
if (currentWindow?.show || currentWindow?.focus) focused = true;
|
||||
electron?.remote?.app?.focus?.({ steal: true });
|
||||
if (electron?.remote?.app?.focus) focused = true;
|
||||
} catch {
|
||||
// Electron focus is best effort; platform fallback may still work.
|
||||
}
|
||||
|
||||
try {
|
||||
rendererWindow()?.focus?.();
|
||||
focused = true;
|
||||
} catch {
|
||||
// Browser window focus is also best effort.
|
||||
}
|
||||
return focused;
|
||||
}
|
||||
|
||||
export async function focusObsidianApp(): Promise<void> {
|
||||
let focused = focusElectronWindow();
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
await execFile("/usr/bin/open", ["-b", "md.obsidian"]);
|
||||
focused = true;
|
||||
} catch {
|
||||
try {
|
||||
await execFile("/usr/bin/open", ["-a", "Obsidian"]);
|
||||
focused = true;
|
||||
} catch (error) {
|
||||
if (!focused) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!focused) {
|
||||
throw new Error("No available Obsidian focus mechanism.");
|
||||
}
|
||||
}
|
||||
|
||||
export class ExternalFileOpenerFeature {
|
||||
constructor(private readonly options: ExternalFileOpenerOptions) {}
|
||||
|
||||
allowedExtensions(): string[] {
|
||||
return externalFileAllowedExtensions(this.options.getSettings());
|
||||
}
|
||||
|
||||
async openExternalFile(
|
||||
rawExternalPath: string,
|
||||
options: { makeFrontmost?: boolean } = {},
|
||||
): Promise<ExternalFileOpenResult> {
|
||||
let externalPath = "";
|
||||
try {
|
||||
const settings = this.options.getSettings();
|
||||
if (!settings.externalFileOpener.enabled) {
|
||||
throw new Error("默认文件打开器已关闭。");
|
||||
}
|
||||
|
||||
externalPath = normalizeExternalFilePath(rawExternalPath);
|
||||
if (!isExternalFileExtensionAllowed(settings, externalPath)) {
|
||||
throw new Error(
|
||||
`不支持该后缀:.${normalizeExternalFileExtension(externalPath) || "unknown"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(externalPath);
|
||||
if (!stat.isFile()) throw new Error("只能打开文件,不能打开文件夹。");
|
||||
|
||||
const mapping = await this.linkExternalFile(externalPath);
|
||||
await this.options.saveSettings();
|
||||
|
||||
const file = await this.waitForIndexedFile(mapping.vaultPath);
|
||||
if (!file) {
|
||||
throw new Error(`Obsidian 尚未索引镜像文件:${mapping.vaultPath}`);
|
||||
}
|
||||
|
||||
const makeFrontmost = options.makeFrontmost !== false;
|
||||
const leaf = this.options.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file, { active: makeFrontmost });
|
||||
if (makeFrontmost) {
|
||||
await this.revealLeaf(leaf);
|
||||
await this.focusObsidianAppBestEffort();
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
externalPath,
|
||||
vaultPath: mapping.vaultPath,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
externalPath: externalPath || rawExternalPath,
|
||||
vaultPath: null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async focusObsidianAppBestEffort(): Promise<void> {
|
||||
try {
|
||||
await (this.options.focusObsidianApp ?? focusObsidianApp)();
|
||||
} catch (error) {
|
||||
console.warn("[mv-senceai-ide] Failed to focus Obsidian.", error);
|
||||
}
|
||||
}
|
||||
|
||||
async pruneBrokenMappings(): Promise<number> {
|
||||
const settings = this.options.getSettings().externalFileOpener;
|
||||
let removed = 0;
|
||||
for (const [externalPath, mapping] of Object.entries(settings.mappings)) {
|
||||
if (
|
||||
!fs.existsSync(externalPath) ||
|
||||
!this.isSymlinkValid(mapping.vaultPath, externalPath)
|
||||
) {
|
||||
this.removeMirrorSymlink(mapping.vaultPath);
|
||||
delete settings.mappings[externalPath];
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) await this.options.saveSettings();
|
||||
return removed;
|
||||
}
|
||||
|
||||
private async linkExternalFile(externalPath: string): Promise<ExternalFileMapping> {
|
||||
const settings = this.options.getSettings().externalFileOpener;
|
||||
const existing = settings.mappings[externalPath];
|
||||
if (existing && this.isSymlinkValid(existing.vaultPath, externalPath)) {
|
||||
return existing;
|
||||
}
|
||||
if (existing) this.removeMirrorSymlink(existing.vaultPath);
|
||||
|
||||
const extension = normalizeExternalFileExtension(externalPath);
|
||||
const preferred = externalFileMirrorPath(settings.mirrorFolder, externalPath);
|
||||
const vaultPath = this.availableMirrorPath(preferred, externalPath);
|
||||
this.createMirrorSymlink(externalPath, vaultPath);
|
||||
|
||||
const mapping: ExternalFileMapping = {
|
||||
externalPath,
|
||||
vaultPath,
|
||||
createdAt: Date.now(),
|
||||
extension,
|
||||
};
|
||||
settings.mappings[externalPath] = mapping;
|
||||
return mapping;
|
||||
}
|
||||
|
||||
private availableMirrorPath(preferred: string, externalPath: string): string {
|
||||
const vaultRoot = this.options.getVaultRoot();
|
||||
const parsed = path.posix.parse(preferred);
|
||||
for (let index = 0; index < 100; index++) {
|
||||
const suffix = index === 0 ? "" : `-${index}`;
|
||||
const candidate = normalizeVaultPath(
|
||||
path.posix.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext}`),
|
||||
);
|
||||
const absolute = path.join(vaultRoot, candidate);
|
||||
try {
|
||||
const stat = fs.lstatSync(absolute);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = fs.readlinkSync(absolute);
|
||||
if (comparePaths(target, externalPath)) return candidate;
|
||||
}
|
||||
} catch {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error("无法为外部文件分配镜像路径。");
|
||||
}
|
||||
|
||||
private createMirrorSymlink(externalPath: string, vaultPath: string): void {
|
||||
const absolute = path.join(this.options.getVaultRoot(), vaultPath);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
try {
|
||||
const stat = fs.lstatSync(absolute);
|
||||
if (stat.isSymbolicLink()) fs.unlinkSync(absolute);
|
||||
} catch {
|
||||
// Missing path is expected.
|
||||
}
|
||||
fs.symlinkSync(externalPath, absolute, "file");
|
||||
}
|
||||
|
||||
private removeMirrorSymlink(vaultPath: string): void {
|
||||
const absolute = path.join(this.options.getVaultRoot(), vaultPath);
|
||||
try {
|
||||
if (fs.lstatSync(absolute).isSymbolicLink()) fs.unlinkSync(absolute);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
|
||||
private isSymlinkValid(vaultPath: string, externalPath: string): boolean {
|
||||
try {
|
||||
const absolute = path.join(this.options.getVaultRoot(), vaultPath);
|
||||
const stat = fs.lstatSync(absolute);
|
||||
return stat.isSymbolicLink() && comparePaths(fs.readlinkSync(absolute), externalPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForIndexedFile(vaultPath: string): Promise<TFile | null> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const file = this.options.app.vault.getAbstractFileByPath(vaultPath);
|
||||
if (file instanceof TFile) return file;
|
||||
await new Promise((resolve) => activeWindow.setTimeout(resolve, 250));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async revealLeaf(leaf: WorkspaceLeaf): Promise<void> {
|
||||
const workspace = this.options.app.workspace as unknown as {
|
||||
revealLeaf?: (target: WorkspaceLeaf) => Promise<void>;
|
||||
};
|
||||
await workspace.revealLeaf?.(leaf);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
createSourceAssistProfile,
|
||||
normalizeSourceAssistExtension,
|
||||
} from "./source-assist/source-assist-settings";
|
||||
import { externalFileAllowedExtensions } from "./external-file-opener";
|
||||
import { getDefaultSourceAssistSnippetVariables } from "./source-assist/default-snippet-variables";
|
||||
import { createSourceAssistSnippetsEditor } from "./source-assist/snippets-editor";
|
||||
import { parseSnippets } from "./vendor/latex-suite/src/snippets/parse";
|
||||
|
|
@ -59,7 +60,8 @@ type MainSettingsSectionId =
|
|||
| "llm"
|
||||
| "inline-completion"
|
||||
| "terminal"
|
||||
| "source-assist";
|
||||
| "source-assist"
|
||||
| "external-file-opener";
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
manual: "手动覆盖",
|
||||
|
|
@ -575,6 +577,11 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
|
|||
"source-assist",
|
||||
"源码编写辅助",
|
||||
);
|
||||
const externalFileOpenerEl = this.createSettingsSection(
|
||||
rootEl,
|
||||
"external-file-opener",
|
||||
"默认文件打开器",
|
||||
);
|
||||
let containerEl = ideEl;
|
||||
|
||||
const claudeSetting = new Setting(containerEl)
|
||||
|
|
@ -696,6 +703,9 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
|
|||
containerEl = sourceAssistEl;
|
||||
this.renderSourceAssistSettings(containerEl);
|
||||
|
||||
containerEl = externalFileOpenerEl;
|
||||
this.renderExternalFileOpenerSettings(containerEl);
|
||||
|
||||
containerEl = ideEl;
|
||||
addHeading(containerEl, "视觉辅助");
|
||||
new Setting(containerEl)
|
||||
|
|
@ -1555,6 +1565,88 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
|
|||
);
|
||||
}
|
||||
|
||||
private renderExternalFileOpenerSettings(containerEl: HTMLElement): void {
|
||||
const settings = this.plugin.settings.externalFileOpener;
|
||||
const supportedExtensions = externalFileAllowedExtensions(this.plugin.settings)
|
||||
.map((extension) => `.${extension}`)
|
||||
.join("、");
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("启用默认文件打开器")
|
||||
.setDesc(
|
||||
"开启后,本插件会启动本地服务,供系统默认打开器 wrapper 打开电脑上的外部文件。",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(settings.enabled).onChange(async (value) => {
|
||||
settings.enabled = value;
|
||||
await this.plugin.saveAndApplySettings();
|
||||
this.rerenderSettings("external-file-opener");
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("支持的后缀范围")
|
||||
.setDesc(`当前支持:${supportedExtensions}`)
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("markdown-only", "仅支持 md")
|
||||
.addOption("markdown-and-source-assist", "支持扩展后缀名")
|
||||
.setValue(settings.extensionMode)
|
||||
.setDisabled(!settings.enabled)
|
||||
.onChange(async (value) => {
|
||||
settings.extensionMode =
|
||||
value === "markdown-and-source-assist"
|
||||
? "markdown-and-source-assist"
|
||||
: "markdown-only";
|
||||
await this.plugin.saveAndApplySettings();
|
||||
this.rerenderSettings("external-file-opener");
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("系统默认打开方式")
|
||||
.setDesc(this.plugin.defaultFileOpenerStatus)
|
||||
.addButton((button) =>
|
||||
button.setButtonText("检查").onClick(() => {
|
||||
const status = this.plugin.checkDefaultFileOpener();
|
||||
new Notice(status.message);
|
||||
this.rerenderSettings("external-file-opener");
|
||||
}),
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("一键注入")
|
||||
.setCta()
|
||||
.setDisabled(!settings.enabled)
|
||||
.onClick(async () => {
|
||||
await this.plugin.installDefaultFileOpener();
|
||||
this.rerenderSettings("external-file-opener");
|
||||
}),
|
||||
)
|
||||
.addButton((button) =>
|
||||
button.setButtonText("清理").onClick(async () => {
|
||||
await this.plugin.cleanupDefaultFileOpener();
|
||||
this.rerenderSettings("external-file-opener");
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("镜像目录")
|
||||
.setDesc("外部文件会以 symlink 形式映射到此 vault 内目录。")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(settings.mirrorFolder)
|
||||
.setPlaceholder("senceai-external-files/mirror")
|
||||
.setDisabled(!settings.enabled)
|
||||
.onChange(async (value) => {
|
||||
settings.mirrorFolder =
|
||||
value.trim().replace(/^\/+/, "") ||
|
||||
DEFAULT_SETTINGS.externalFileOpener.mirrorFolder;
|
||||
await this.plugin.saveData(this.plugin.settings);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderSourceAssistSettings(containerEl: HTMLElement): void {
|
||||
const settings = this.plugin.settings.sourceAssist;
|
||||
|
||||
|
|
|
|||
20
src/types.ts
20
src/types.ts
|
|
@ -226,6 +226,25 @@ export interface SourceAssistSettings {
|
|||
profiles: SourceAssistProfile[];
|
||||
}
|
||||
|
||||
export type ExternalFileOpenerExtensionMode =
|
||||
| "markdown-only"
|
||||
| "markdown-and-source-assist";
|
||||
|
||||
export interface ExternalFileMapping {
|
||||
externalPath: string;
|
||||
vaultPath: string;
|
||||
createdAt: number;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
export interface ExternalFileOpenerSettings {
|
||||
enabled: boolean;
|
||||
extensionMode: ExternalFileOpenerExtensionMode;
|
||||
mirrorFolder: string;
|
||||
mappings: Record<string, ExternalFileMapping>;
|
||||
openerToken: string;
|
||||
}
|
||||
|
||||
export type TerminalThemeMode = "obsidian" | "light" | "dark" | "custom";
|
||||
|
||||
export interface TerminalThemePalette {
|
||||
|
|
@ -276,6 +295,7 @@ export interface BridgeSettings {
|
|||
llm: LlmFeatureSettings;
|
||||
inlineCompletion: InlineCompletionSettings;
|
||||
sourceAssist: SourceAssistSettings;
|
||||
externalFileOpener: ExternalFileOpenerSettings;
|
||||
mcpEnabled: boolean;
|
||||
mcpAuthToken: string;
|
||||
claudeExecutable: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue