Release 1.1.0: Cursor workspace targeting

This commit is contained in:
Erik Jan Carlstedt 2025-12-23 20:11:29 +01:00
parent 926139edbd
commit 7501a460ad
14 changed files with 277 additions and 14 deletions

1
.gitignore vendored
View file

@ -38,6 +38,7 @@ pnpm-debug.log*
.turbo/
.cache/
npm-cache/
.npm-cache/
# Editor directories
.vscode/

View file

@ -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.

View file

@ -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.

View file

@ -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 cant 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 cant 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.

View file

@ -0,0 +1,13 @@
{
"folders": [
{
"name": "Vault Root",
"path": "./VaultRoot"
},
{
"name": "Python Scripts",
"path": "./VaultRoot/.obsidian/python_scripts"
}
]
}

View file

@ -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"`]);
});
});

View 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();
});
});

View file

@ -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";
}

View file

@ -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 };
}

View file

@ -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": "",

View file

@ -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",

View file

@ -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.")

View file

@ -1,3 +1,4 @@
{
"1.0.0": "1.5.0"
}
"1.0.0": "1.5.0",
"1.1.0": "1.5.0"
}

View 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
};
}