mirror of
https://github.com/carlstedt1/open-in-ide.git
synced 2026-07-22 06:41:02 +00:00
Release 1.1.0: Cursor workspace targeting
This commit is contained in:
parent
926139edbd
commit
7501a460ad
14 changed files with 277 additions and 14 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,6 +38,7 @@ pnpm-debug.log*
|
|||
.turbo/
|
||||
.cache/
|
||||
npm-cache/
|
||||
.npm-cache/
|
||||
|
||||
# Editor directories
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ Tests live in `__tests__/launcher/commandBuilder.test.ts` and verify the Cursor
|
|||
|
||||
- With a Cursor window already open on the vault, run the command → window remains and the target file is focused.
|
||||
- With Cursor closed, run the command → new window opens, vault loads, file is focused.
|
||||
- If **Cursor workspace file** is configured and the active file belongs to that workspace, run the command → the workspace window is focused (or opened) before the file is targeted.
|
||||
- If Obsidian was launched without a full PATH, set **Cursor executable path** and verify no `spawn cursor ENOENT` errors occur.
|
||||
- Test with markdown files (`.md`) → should work as before.
|
||||
- Test with `.base` files → should open correctly.
|
||||
- Test with `.canvas` files → should open correctly.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# Changelog
|
||||
|
||||
## 1.1.0 - 2025-12-23
|
||||
- **Cursor workspace targeting**: Optional `.code-workspace` setting that focuses/opens the workspace when the active file belongs to it.
|
||||
- **More reliable Cursor CLI launching on macOS**: Automatically tries common CLI locations when PATH lookup fails.
|
||||
|
||||
## 1.0.0 - 2025-11-18
|
||||
- Stable release.
|
||||
- Supports opening any vault file type and preserving cursor position when available.
|
||||
|
||||
## 0.2.0 - 2025-11-11
|
||||
- **Extended file type support**: Plugin now works with all file types in the vault, not just markdown files.
|
||||
- Support for `.base`, `.canvas`, and any other file type in your vault.
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -1,14 +1,15 @@
|
|||
# Open in IDE
|
||||
|
||||
Open the current Obsidian file in your IDE of choice. Supports all file types in your vault (`.md`, `.base`, `.canvas`, and more). Version 1.0.0 extends support beyond markdown files, with Cursor support that reuses existing windows when possible and stages the vault before jumping to the file.
|
||||
Open the current Obsidian file in your IDE of choice. Supports all file types in your vault (`.md`, `.base`, `.canvas`, and more). Cursor support reuses existing windows when possible and can optionally focus a configured Cursor workspace (`.code-workspace`) before jumping to the file.
|
||||
|
||||
**Current version: 1.0.0**
|
||||
**Current version: 1.1.0**
|
||||
|
||||
## ✨ Features
|
||||
- Command palette action + optional hotkey
|
||||
- Reuse an existing Cursor window (or spawn a new one)
|
||||
- (Optional) Focus a specific Cursor workspace (`.code-workspace`) when the active file belongs to it
|
||||
- Jump to the active file and cursor position (supports all file types)
|
||||
- Settings for CLI path, vault staging, reuse behaviour, and OS fallbacks
|
||||
- Settings for CLI path, workspace targeting, vault staging, reuse behaviour, and OS fallbacks
|
||||
|
||||
## ✅ Currently supported IDEs
|
||||
|
||||
|
|
@ -20,10 +21,11 @@ Next up: Neovim, JetBrains, and more.
|
|||
|
||||
## 🧩 Requirements
|
||||
- Desktop Obsidian (relies on `FileSystemAdapter`)
|
||||
- Cursor CLI on your PATH
|
||||
- Cursor CLI installed
|
||||
- Install within Cursor via `Cmd+Shift+P` → `Shell Command: Install "cursor"`
|
||||
- macOS Homebrew: `brew install --cask cursor`
|
||||
- Windows: ensure Cursor is installed and restart the terminal so `%LocalAppData%\Programs\cursor\bin` is on PATH.
|
||||
- If Obsidian can’t find `cursor` on PATH (common when launched from Finder), set **Cursor executable path** in settings.
|
||||
- Tested on macOS; other platforms have not yet been formally certified
|
||||
|
||||
## 🚀 Installation
|
||||
|
|
@ -48,6 +50,7 @@ Alternatively, for manual installation:
|
|||
| Setting | Description |
|
||||
| ---------------------- | ------------------------------------------------------------ |
|
||||
| Cursor executable path | Override the Cursor binary location |
|
||||
| Cursor workspace file | Optional `.code-workspace` to focus/open when the file belongs to it |
|
||||
| Reuse existing window | Prefer existing Cursor windows for the vault |
|
||||
| Open vault before file | Ensure the vault is loaded into Cursor before the note |
|
||||
| Allow system fallback | Use `open` / `start` / `xdg-open` if the CLI cannot be found |
|
||||
|
|
@ -62,6 +65,7 @@ Alternatively, for manual installation:
|
|||
- Cursor CLI is strongly recommended—fallback launchers are best-effort
|
||||
- Only supports files inside the vault
|
||||
- Window reuse ultimately depends on the Cursor CLI
|
||||
- The plugin can’t inspect “currently open” Cursor windows; it targets a workspace/folder and lets Cursor focus an existing matching window when available
|
||||
- Cursor position is only preserved for files with an active text editor
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for release history and [AGENTS.md](./AGENTS.md) for developer notes.
|
||||
|
|
|
|||
13
__tests__/fixtures/obsidian-main.code-workspace
Normal file
13
__tests__/fixtures/obsidian-main.code-workspace
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "Vault Root",
|
||||
"path": "./VaultRoot"
|
||||
},
|
||||
{
|
||||
"name": "Python Scripts",
|
||||
"path": "./VaultRoot/.obsidian/python_scripts"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { pathToFileURL } from "url";
|
||||
import { buildCursorCommand } from "../../launcher/testable";
|
||||
import { buildCursorCommand, buildCursorWorkspaceCommand } from "../../launcher/testable";
|
||||
|
||||
describe("buildCursorCommand", () => {
|
||||
const vault = "/Users/example/Vault";
|
||||
|
|
@ -39,3 +39,21 @@ describe("buildCursorCommand", () => {
|
|||
expect(args).toEqual([note]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCursorWorkspaceCommand", () => {
|
||||
it("builds command that opens workspace and targets file", () => {
|
||||
const workspaceFile = "/Users/example/Obsidian-Main.code-workspace";
|
||||
const note = "/Users/example/Vault/testing note.md";
|
||||
|
||||
const { command, args } = buildCursorWorkspaceCommand({
|
||||
command: "cursor",
|
||||
workspaceFilePath: workspaceFile,
|
||||
filePath: note,
|
||||
line: 5,
|
||||
column: 2
|
||||
});
|
||||
|
||||
expect(command).toBe("cursor");
|
||||
expect(args).toEqual([workspaceFile, "--goto", `"${note}:5:2"`]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
32
__tests__/workspaces/workspaceMatcher.test.ts
Normal file
32
__tests__/workspaces/workspaceMatcher.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import * as path from "path";
|
||||
|
||||
import { findWorkspaceMatchForFile } from "../../workspaces/workspaceMatcher";
|
||||
|
||||
describe("findWorkspaceMatchForFile", () => {
|
||||
const workspaceFilePath = path.join(__dirname, "..", "fixtures", "obsidian-main.code-workspace");
|
||||
const fixtureRoot = path.resolve(path.dirname(workspaceFilePath));
|
||||
|
||||
it("matches when file is under a workspace folder", () => {
|
||||
const filePath = path.join(fixtureRoot, "VaultRoot", "Note.md");
|
||||
const match = findWorkspaceMatchForFile(workspaceFilePath, filePath);
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.workspaceFilePath).toBe(workspaceFilePath);
|
||||
expect(match?.matchedFolderPath.endsWith(path.join("VaultRoot"))).toBe(true);
|
||||
});
|
||||
|
||||
it("picks the most specific folder match", () => {
|
||||
const filePath = path.join(fixtureRoot, "VaultRoot", ".obsidian", "python_scripts", "tool.py");
|
||||
const match = findWorkspaceMatchForFile(workspaceFilePath, filePath);
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.matchedFolderPath.endsWith(path.join("VaultRoot", ".obsidian", "python_scripts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null when workspace does not include the file", () => {
|
||||
const filePath = path.join(fixtureRoot, "Other", "Note.md");
|
||||
const match = findWorkspaceMatchForFile(workspaceFilePath, filePath);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,8 @@ import { existsSync } from "fs";
|
|||
import type { PluginSettings } from "../settings";
|
||||
import { assertDesktopFeature, resolveVaultAbsolutePath, resolveVaultRoot } from "../utils/helpers";
|
||||
import { pathToFileURL } from "url";
|
||||
import { buildCursorCommand } from "./testable";
|
||||
import { buildCursorCommand, buildCursorWorkspaceCommand } from "./testable";
|
||||
import { findWorkspaceMatchForFile } from "../workspaces/workspaceMatcher";
|
||||
|
||||
export interface LaunchResult {
|
||||
ok: boolean;
|
||||
|
|
@ -33,6 +34,18 @@ export async function openFileInCursor(app: App, settings: PluginSettings, file:
|
|||
const vaultPath = resolveVaultRoot(app);
|
||||
const command = resolveCursorExecutable(settings);
|
||||
|
||||
const workspaceMatch = settings.cursorWorkspaceFilePath
|
||||
? findWorkspaceMatchForFile(settings.cursorWorkspaceFilePath, filePath)
|
||||
: null;
|
||||
|
||||
if (settings.cursorWorkspaceFilePath && !workspaceMatch) {
|
||||
console.log("[open-in-ide] Cursor workspace configured but file is not in workspace folders", {
|
||||
workspaceFilePath: settings.cursorWorkspaceFilePath,
|
||||
filePath,
|
||||
vaultPath
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.cursorExecutablePath && !existsSync(settings.cursorExecutablePath)) {
|
||||
console.error("[open-in-ide] Cursor executable missing", settings.cursorExecutablePath);
|
||||
return {
|
||||
|
|
@ -41,6 +54,32 @@ export async function openFileInCursor(app: App, settings: PluginSettings, file:
|
|||
};
|
||||
}
|
||||
|
||||
if (workspaceMatch) {
|
||||
const { command: workspaceCommand, args: workspaceArgs } = buildCursorWorkspaceCommand({
|
||||
command,
|
||||
workspaceFilePath: workspaceMatch.workspaceFilePath,
|
||||
filePath,
|
||||
line: position?.line,
|
||||
column: position?.column
|
||||
});
|
||||
|
||||
console.log("[open-in-ide] Launching Cursor workspace", {
|
||||
command: workspaceCommand,
|
||||
args: workspaceArgs,
|
||||
filePath,
|
||||
workspaceFilePath: workspaceMatch.workspaceFilePath,
|
||||
matchedFolderPath: workspaceMatch.matchedFolderPath,
|
||||
position
|
||||
});
|
||||
|
||||
try {
|
||||
await spawnDetached(workspaceCommand, workspaceArgs);
|
||||
return { ok: true, message: "Sent to Cursor workspace." };
|
||||
} catch (error) {
|
||||
console.warn("[open-in-ide] Failed to open Cursor workspace", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.openVaultBeforeFile && vaultPath && !initializedVaults.has(vaultPath)) {
|
||||
const addArgs = buildAddFolderArgs(settings, vaultPath);
|
||||
console.log("[open-in-ide] Ensuring vault present in Cursor workspace", {
|
||||
|
|
@ -111,6 +150,16 @@ function resolveCursorExecutable(settings: PluginSettings): string {
|
|||
if (settings.cursorExecutablePath) {
|
||||
return settings.cursorExecutablePath;
|
||||
}
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
const candidates = ["/usr/local/bin/cursor", "/opt/homebrew/bin/cursor"];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Platform.isWin ? "cursor.exe" : "cursor";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ export interface CursorCommand {
|
|||
args: string[];
|
||||
}
|
||||
|
||||
export interface BuildWorkspaceCommandOptions {
|
||||
command: string;
|
||||
workspaceFilePath: string;
|
||||
filePath: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export function buildCursorCommand(options: BuildCommandOptions): CursorCommand {
|
||||
const { command, vaultPath, filePath, line, column, reuse, includeVault } = options;
|
||||
const args: string[] = [];
|
||||
|
|
@ -33,8 +41,7 @@ export function buildCursorCommand(options: BuildCommandOptions): CursorCommand
|
|||
const target = `${filePath}:${lineNumber}:${columnNumber}`;
|
||||
args.push("--goto", target.includes(" ") ? `"${target}"` : target);
|
||||
} else {
|
||||
// Quote file path if it contains spaces (same logic as --goto target)
|
||||
args.push(filePath.includes(" ") ? `"${filePath}"` : filePath);
|
||||
args.push(filePath);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -42,3 +49,19 @@ export function buildCursorCommand(options: BuildCommandOptions): CursorCommand
|
|||
args
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCursorWorkspaceCommand(options: BuildWorkspaceCommandOptions): CursorCommand {
|
||||
const { command, workspaceFilePath, filePath, line, column } = options;
|
||||
const args: string[] = [workspaceFilePath];
|
||||
|
||||
if (line !== undefined) {
|
||||
const lineNumber = Math.max(1, line);
|
||||
const columnNumber = column !== undefined ? Math.max(1, column) : 1;
|
||||
const target = `${filePath}:${lineNumber}:${columnNumber}`;
|
||||
args.push("--goto", target.includes(" ") ? `"${target}"` : target);
|
||||
} else {
|
||||
args.push(filePath);
|
||||
}
|
||||
|
||||
return { command, args };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "open-in-ide",
|
||||
"name": "Open in IDE",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Open the active file in your IDE (supports all file types including .base, .canvas, and more).",
|
||||
"description": "Open the active file in Cursor (supports all file types including .base, .canvas, and more) with optional workspace targeting.",
|
||||
"author": "Erik Carlstedt",
|
||||
"authorUrl": "",
|
||||
"fundingUrl": "",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "open-in-ide",
|
||||
"version": "1.0.0",
|
||||
"description": "Obsidian plugin that opens the active note in Cursor (and future IDEs).",
|
||||
"version": "1.1.0",
|
||||
"description": "Obsidian plugin that opens the active file in Cursor (and future IDEs).",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
16
settings.ts
16
settings.ts
|
|
@ -3,6 +3,8 @@ import { App, Notice, Plugin, PluginSettingTab, Setting } from "obsidian";
|
|||
export interface PluginSettings {
|
||||
/** Absolute path to the Cursor CLI binary. Leave empty to use PATH lookup. */
|
||||
cursorExecutablePath: string;
|
||||
/** Optional path to a .code-workspace file to target when the active file belongs to it. */
|
||||
cursorWorkspaceFilePath: string;
|
||||
/** Attempt to reuse the existing Cursor window if one is open on the vault. */
|
||||
reuseExistingWindow: boolean;
|
||||
/** Open the vault folder before targeting the active file. */
|
||||
|
|
@ -13,6 +15,7 @@ export interface PluginSettings {
|
|||
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
cursorExecutablePath: "",
|
||||
cursorWorkspaceFilePath: "",
|
||||
reuseExistingWindow: true,
|
||||
openVaultBeforeFile: true,
|
||||
allowSystemFallback: true
|
||||
|
|
@ -54,6 +57,19 @@ export class OpenInIDESettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Cursor workspace file (optional)")
|
||||
.setDesc("If set, the plugin will try to focus/open this .code-workspace when the active file is inside one of its folders.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("/Users/you/Obsidian-Main.code-workspace")
|
||||
.setValue(this.plugin.settings.cursorWorkspaceFilePath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.cursorWorkspaceFilePath = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Reuse existing window")
|
||||
.setDesc("Request Cursor to reuse an open window for this vault when possible.")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"1.0.0": "1.5.0"
|
||||
}
|
||||
"1.0.0": "1.5.0",
|
||||
"1.1.0": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
96
workspaces/workspaceMatcher.ts
Normal file
96
workspaces/workspaceMatcher.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
export interface WorkspaceFolder {
|
||||
name?: string;
|
||||
path?: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceDefinition {
|
||||
folders?: WorkspaceFolder[];
|
||||
}
|
||||
|
||||
export interface WorkspaceMatch {
|
||||
workspaceFilePath: string;
|
||||
matchedFolderPath: string;
|
||||
}
|
||||
|
||||
type WorkspaceCacheEntry = {
|
||||
mtimeMs: number;
|
||||
folders: string[];
|
||||
};
|
||||
|
||||
const workspaceCache = new Map<string, WorkspaceCacheEntry>();
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function isPathInside(parentPath: string, candidatePath: string): boolean {
|
||||
const relative = path.relative(parentPath, candidatePath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function loadWorkspaceFolders(workspaceFilePath: string): string[] {
|
||||
const stat = statSync(workspaceFilePath);
|
||||
const cached = workspaceCache.get(workspaceFilePath);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
||||
return cached.folders;
|
||||
}
|
||||
|
||||
const raw = readFileSync(workspaceFilePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as WorkspaceDefinition;
|
||||
const baseDir = path.dirname(workspaceFilePath);
|
||||
|
||||
const folders: string[] = [];
|
||||
for (const folder of parsed.folders ?? []) {
|
||||
if (!folder.path || typeof folder.path !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.isAbsolute(folder.path) ? folder.path : path.join(baseDir, folder.path);
|
||||
folders.push(normalizePath(resolved));
|
||||
}
|
||||
|
||||
workspaceCache.set(workspaceFilePath, { mtimeMs: stat.mtimeMs, folders });
|
||||
return folders;
|
||||
}
|
||||
|
||||
export function findWorkspaceMatchForFile(workspaceFilePath: string, filePath: string): WorkspaceMatch | null {
|
||||
if (!workspaceFilePath.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existsSync(workspaceFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let folders: string[];
|
||||
try {
|
||||
folders = loadWorkspaceFolders(workspaceFilePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedFilePath = normalizePath(filePath);
|
||||
|
||||
let bestMatch: string | null = null;
|
||||
for (const folderPath of folders) {
|
||||
if (!folderPath) continue;
|
||||
if (!isPathInside(folderPath, normalizedFilePath)) continue;
|
||||
|
||||
if (!bestMatch || folderPath.length > bestMatch.length) {
|
||||
bestMatch = folderPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceFilePath,
|
||||
matchedFolderPath: bestMatch
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue