mirror of
https://github.com/carlstedt1/open-in-ide.git
synced 2026-07-22 06:41:02 +00:00
Initial commit v0.1.0, working with Cursor IDE
This commit is contained in:
commit
bbe1fd14a1
18 changed files with 5168 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
main.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# test artifacts
|
||||
coverage/
|
||||
.vitest_output/
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
67
AGENTS.md
Normal file
67
AGENTS.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Open in IDE – Developer Notes
|
||||
|
||||
Internal reference for building, testing, and releasing the plugin. Keep the public README focused on user-facing documentation.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: current LTS (Node 18+ recommended).
|
||||
- npm for dependency management.
|
||||
- esbuild for bundling (`esbuild.config.mjs`).
|
||||
- TypeScript 4.7.x, `obsidian` type definitions, Vitest 0.34 for unit coverage.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # watch mode (esbuild)
|
||||
```
|
||||
|
||||
Reload the plugin in Obsidian via **Settings → Community plugins** whenever `main.js` changes.
|
||||
|
||||
### Unit tests
|
||||
|
||||
Vitest 0.34 (compatible with the Node 16 type definitions shipped here).
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
Tests live in `__tests__/launcher/commandBuilder.test.ts` and verify the Cursor CLI argument construction helper.
|
||||
|
||||
## Manual QA checklist
|
||||
|
||||
- With a Cursor window already open on the vault, run the command → window remains and the target note is focused.
|
||||
- With Cursor closed, run the command → new window opens, vault loads, note is focused.
|
||||
- Paths containing spaces or iCloud-style prefixes resolve correctly.
|
||||
- Toggle **Open vault before file** off → command still opens the current note.
|
||||
- Set an invalid executable path → notice appears and no crash occurs.
|
||||
- Disable **Allow system fallback** → command fails gracefully when the CLI is missing.
|
||||
- When possible, test fallbacks on macOS (`open`), Windows (`start`), and Linux (`xdg-open`).
|
||||
|
||||
## Release workflow
|
||||
|
||||
1. Update version numbers in `manifest.json`, `package.json`, and `versions.json`.
|
||||
2. Run `npm run build` to produce `main.js`.
|
||||
3. Package `main.js`, `manifest.json`, and `styles.css` (if present) as release artifacts.
|
||||
4. Update `CHANGELOG.md` and README screenshots/usage notes.
|
||||
5. Tag the release (`git tag <version>`), push the tag, and create the GitHub release with the artifacts attached.
|
||||
|
||||
## Planned CI/CD pipeline
|
||||
|
||||
1. **Branch strategy** – use feature branches; merge to a staging branch (e.g., `develop`), then promote to `main` for releases.
|
||||
2. **GitHub Actions** – on push/tag:
|
||||
- `npm install`
|
||||
- `npm run test`
|
||||
- `npm run build`
|
||||
- Upload artifacts as workflow outputs.
|
||||
3. **Release automation** – on tag:
|
||||
- Create GitHub release, attach artifacts.
|
||||
- Optionally open/update PR against `obsidianmd/obsidian-releases` with the new `manifest.json`.
|
||||
4. **Secrets** – store GitHub tokens needed for releases and community plugin submissions.
|
||||
|
||||
## Additional references
|
||||
|
||||
- Obsidian sample plugin: <https://github.com/obsidianmd/obsidian-sample-plugin>
|
||||
- API documentation: <https://docs.obsidian.md>
|
||||
- Developer policies & plugin guidelines: <https://docs.obsidian.md/Developer+policies>
|
||||
- Plugin submission workflow: <https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines>
|
||||
7
CHANGELOG.md
Normal file
7
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Changelog
|
||||
|
||||
## 0.1.0 - 2024-09-05
|
||||
- Initial MVP branded as Open in IDE (formerly Open in Cursor).
|
||||
- Command palette action opens the active note in Cursor, reuses existing window, ensures vault presence.
|
||||
- Settings for CLI path overrides, vault staging, window reuse, and fallback behaviour.
|
||||
- Added unit coverage for command argument construction.
|
||||
54
README.md
Normal file
54
README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Open in IDE
|
||||
|
||||
Open the active Obsidian note in your IDE of choice. The first release focuses on Cursor support, reusing existing windows whenever possible and making sure the vault is loaded before jumping to the note.
|
||||
|
||||
## Currently supported IDEs
|
||||
|
||||
- Cursor (macOS tested)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Desktop Obsidian (the plugin relies on the `FileSystemAdapter` API).
|
||||
- Cursor CLI installed and available on your PATH, or the absolute path configured in the plugin settings.
|
||||
- Tested on macOS; other platforms should work but have not yet been formally certified.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone or copy this repository into your vault as `Vault/.obsidian/plugins/open-in-ide`.
|
||||
2. In Obsidian, go to **Settings → Community plugins** and enable “Open in IDE”.
|
||||
3. Optional: assign a hotkey in **Settings → Hotkeys → Open in IDE**.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open a Markdown file in Obsidian’s editor (the command appears only when an editor is active).
|
||||
2. Run the command palette entry `Open in IDE (Cursor)` (or use your assigned hotkey).
|
||||
3. Cursor reuses an existing window for the vault when it can, then focuses the active note at the current cursor location.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Cursor executable path** – point to the Cursor binary if it isn’t on your PATH (leave blank to auto-detect).
|
||||
- **Reuse existing window** – reuse existing Cursor windows for the vault (default: enabled).
|
||||
- **Open vault before file** – ensure the vault root is present in Cursor before opening the note (default: enabled).
|
||||
- **Allow system fallback** – use OS launchers (`open`, `start`, `xdg-open`) if the Cursor CLI cannot be found.
|
||||
|
||||
### Error messages
|
||||
|
||||
- *“Cursor executable not found …”* – update the executable path or clear the field to use PATH lookup.
|
||||
- *“Unable to resolve absolute path for the active file.”* – the plugin can only open Markdown files stored in the vault.
|
||||
- *Fallback notices* – the OS-specific opener was used instead of the Cursor CLI.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Desktop-only; the plugin is disabled on mobile.
|
||||
- Requires the Cursor CLI; without it the plugin can only attempt best-effort fallbacks.
|
||||
- Window-reuse behaviour ultimately depends on Cursor’s CLI flags.
|
||||
|
||||
## Manual installation / updates
|
||||
|
||||
1. Run `npm install` inside the plugin folder.
|
||||
2. Build with `npm run dev` (watch mode) or `npm run build` (production bundle).
|
||||
3. Reload the plugin from **Settings → Community plugins** after each build.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for release history.
|
||||
41
__tests__/launcher/commandBuilder.test.ts
Normal file
41
__tests__/launcher/commandBuilder.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { pathToFileURL } from "url";
|
||||
import { buildCursorCommand } from "../../launcher/testable";
|
||||
|
||||
describe("buildCursorCommand", () => {
|
||||
const vault = "/Users/example/Vault";
|
||||
const note = "/Users/example/Vault/testing note.md";
|
||||
|
||||
it("builds command with reuse and folder uri", () => {
|
||||
const { command, args } = buildCursorCommand({
|
||||
command: "cursor",
|
||||
vaultPath: vault,
|
||||
filePath: note,
|
||||
line: 5,
|
||||
column: 2,
|
||||
reuse: true,
|
||||
includeVault: true
|
||||
});
|
||||
|
||||
expect(command).toBe("cursor");
|
||||
expect(args).toEqual([
|
||||
"--reuse-window",
|
||||
"--folder-uri",
|
||||
pathToFileURL(vault).toString(),
|
||||
"--goto",
|
||||
`"${note}:5:2"`
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds command without vault and cursor location", () => {
|
||||
const { args } = buildCursorCommand({
|
||||
command: "cursor",
|
||||
vaultPath: null,
|
||||
filePath: note,
|
||||
reuse: false,
|
||||
includeVault: false
|
||||
});
|
||||
|
||||
expect(args).toEqual([note]);
|
||||
});
|
||||
});
|
||||
46
commands/index.ts
Normal file
46
commands/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Editor, MarkdownView, Notice } from "obsidian";
|
||||
import type { PluginSettings, PluginWithSettings } from "../settings";
|
||||
import { resolveVaultAbsolutePath } from "../utils/helpers";
|
||||
import { openFileInCursor } from "../launcher/cursorLauncher";
|
||||
|
||||
export function registerCommands(plugin: PluginWithSettings<PluginSettings>): void {
|
||||
plugin.addCommand({
|
||||
id: "open-in-ide",
|
||||
name: "Open in IDE (Cursor)",
|
||||
editorCheckCallback: (checking, editor: Editor, view) => {
|
||||
if (!(view instanceof MarkdownView)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cursor = editor.getCursor();
|
||||
const line = cursor?.line !== undefined ? cursor.line + 1 : undefined;
|
||||
const column = cursor?.ch !== undefined ? cursor.ch + 1 : undefined;
|
||||
|
||||
openFileInCursor(plugin.app, plugin.settings, file, { line, column })
|
||||
.then((result) => {
|
||||
if (result.ok) {
|
||||
const absolute = resolveVaultAbsolutePath(plugin.app, file);
|
||||
const payload = absolute ?? `Vault-relative path: ${file.path}`;
|
||||
new Notice(result.message ?? `Opened in Cursor: ${payload}`);
|
||||
} else if (result.message) {
|
||||
new Notice(result.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[open-in-ide] failed to launch Cursor", error);
|
||||
new Notice("Failed to launch Cursor. Check console for details.");
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
42
esbuild.config.mjs
Normal file
42
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner = `/*
|
||||
Bundled by esbuild. Source lives in TypeScript under src/.
|
||||
*/`;
|
||||
|
||||
const isProd = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: { js: banner },
|
||||
entryPoints: ["main.ts"],
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: "es2018",
|
||||
sourcemap: isProd ? false : "inline",
|
||||
treeShaking: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins
|
||||
],
|
||||
outfile: "main.js",
|
||||
minify: isProd
|
||||
});
|
||||
|
||||
if (isProd) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
200
launcher/cursorLauncher.ts
Normal file
200
launcher/cursorLauncher.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { App, Platform, TFile } from "obsidian";
|
||||
import { spawn } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import type { PluginSettings } from "../settings";
|
||||
import { assertDesktopFeature, resolveVaultAbsolutePath, resolveVaultRoot } from "../utils/helpers";
|
||||
import { pathToFileURL } from "url";
|
||||
import { buildCursorCommand } from "./testable";
|
||||
|
||||
export interface LaunchResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CursorPosition {
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
const initializedVaults = new Set<string>();
|
||||
|
||||
export async function openFileInCursor(app: App, settings: PluginSettings, file: TFile, position?: CursorPosition): Promise<LaunchResult> {
|
||||
try {
|
||||
assertDesktopFeature(app);
|
||||
} catch (error) {
|
||||
return { ok: false, message: (error as Error).message };
|
||||
}
|
||||
|
||||
const filePath = resolveVaultAbsolutePath(app, file);
|
||||
if (!filePath) {
|
||||
return { ok: false, message: "Unable to resolve absolute path for the active file." };
|
||||
}
|
||||
|
||||
const vaultPath = resolveVaultRoot(app);
|
||||
const command = resolveCursorExecutable(settings);
|
||||
|
||||
if (settings.cursorExecutablePath && !existsSync(settings.cursorExecutablePath)) {
|
||||
console.error("[open-in-ide] Cursor executable missing", settings.cursorExecutablePath);
|
||||
return {
|
||||
ok: false,
|
||||
message: `Cursor executable not found at ${settings.cursorExecutablePath}. Update the setting or clear it to use PATH lookup.`
|
||||
};
|
||||
}
|
||||
|
||||
if (settings.openVaultBeforeFile && vaultPath && !initializedVaults.has(vaultPath)) {
|
||||
const addArgs = buildAddFolderArgs(settings, vaultPath);
|
||||
console.log("[open-in-ide] Ensuring vault present in Cursor workspace", {
|
||||
command,
|
||||
addArgs,
|
||||
vaultPath
|
||||
});
|
||||
try {
|
||||
await spawnDetached(command, addArgs);
|
||||
initializedVaults.add(vaultPath);
|
||||
} catch (error) {
|
||||
console.warn("[open-in-ide] Failed to add vault to Cursor workspace", error);
|
||||
if (!settings.allowSystemFallback || !isMissingExecutableError(error)) {
|
||||
// Continue with main launch even if add fails; fallbacks handled later.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { command: handlerCommand, args } = buildCursorCommand({
|
||||
command,
|
||||
vaultPath,
|
||||
filePath,
|
||||
line: position?.line,
|
||||
column: position?.column,
|
||||
reuse: settings.reuseExistingWindow,
|
||||
includeVault: Boolean(settings.openVaultBeforeFile && vaultPath)
|
||||
});
|
||||
|
||||
console.log("[open-in-ide] Launching Cursor CLI", {
|
||||
command: handlerCommand,
|
||||
args,
|
||||
filePath,
|
||||
vaultPath,
|
||||
position
|
||||
});
|
||||
|
||||
try {
|
||||
await spawnDetached(handlerCommand, args);
|
||||
const target = args.includes("--goto") ? args[args.length - 1] : filePath;
|
||||
return { ok: true, message: `Sent to Cursor (${target}).` };
|
||||
} catch (error) {
|
||||
if (isMissingExecutableError(error) && settings.allowSystemFallback) {
|
||||
const fallback = await launchViaSystem(filePath, vaultPath, settings);
|
||||
if (fallback.ok) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: `Failed to launch Cursor: ${(error as Error).message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildGotoTarget(filePath: string, position?: CursorPosition): string | null {
|
||||
if (!position || position.line === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const line = Math.max(1, position.line);
|
||||
const column = position.column !== undefined ? Math.max(1, position.column) : 1;
|
||||
const target = `${filePath}:${line}:${column}`;
|
||||
return target.includes(" ") ? `"${target}"` : target;
|
||||
}
|
||||
|
||||
function resolveCursorExecutable(settings: PluginSettings): string {
|
||||
if (settings.cursorExecutablePath) {
|
||||
return settings.cursorExecutablePath;
|
||||
}
|
||||
return Platform.isWin ? "cursor.exe" : "cursor";
|
||||
}
|
||||
|
||||
function buildAddFolderArgs(settings: PluginSettings, vaultPath: string): string[] {
|
||||
const args: string[] = [];
|
||||
if (settings.reuseExistingWindow) {
|
||||
args.push("--reuse-window");
|
||||
}
|
||||
args.push("--folder-uri", pathToFileURL(vaultPath).toString());
|
||||
return args;
|
||||
}
|
||||
|
||||
async function spawnDetached(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
|
||||
const finalize = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.unref();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
child.once("error", (error) => finalize(error));
|
||||
child.once("exit", (code) => {
|
||||
if (code && code !== 0) {
|
||||
finalize(new Error(`${command} exited with code ${code}`));
|
||||
} else {
|
||||
finalize();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isMissingExecutableError(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT");
|
||||
}
|
||||
|
||||
async function launchViaSystem(filePath: string, vaultPath: string | null, settings: PluginSettings): Promise<LaunchResult> {
|
||||
try {
|
||||
if (Platform.isMacOS) {
|
||||
const macArgs = buildMacArgs(filePath, vaultPath, settings);
|
||||
console.log("[open-in-ide] macOS fallback", { macArgs });
|
||||
await spawnDetached("open", macArgs);
|
||||
return { ok: true, message: "Fallback: opened via macOS." };
|
||||
}
|
||||
|
||||
if (Platform.isWin) {
|
||||
const winArgs = buildWindowsArgs(filePath);
|
||||
console.log("[open-in-ide] Windows fallback", { winArgs });
|
||||
await spawnDetached("cmd", winArgs);
|
||||
return { ok: true, message: "Fallback: opened via Windows shell." };
|
||||
}
|
||||
|
||||
await spawnDetached("xdg-open", [filePath]);
|
||||
return { ok: true, message: "Fallback: opened via xdg-open." };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Fallback opener failed: ${(error as Error).message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildMacArgs(filePath: string, vaultPath: string | null, settings: PluginSettings): string[] {
|
||||
const args: string[] = [];
|
||||
args.push("-a", "Cursor");
|
||||
if (settings.openVaultBeforeFile && vaultPath) {
|
||||
args.push(vaultPath);
|
||||
}
|
||||
args.push(filePath);
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildWindowsArgs(filePath: string): string[] {
|
||||
return ["/c", "start", "", '"' + filePath + '"'];
|
||||
}
|
||||
43
launcher/testable.ts
Normal file
43
launcher/testable.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { pathToFileURL } from "url";
|
||||
|
||||
export interface BuildCommandOptions {
|
||||
command: string;
|
||||
vaultPath: string | null;
|
||||
filePath: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
reuse: boolean;
|
||||
includeVault: boolean;
|
||||
}
|
||||
|
||||
export interface CursorCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export function buildCursorCommand(options: BuildCommandOptions): CursorCommand {
|
||||
const { command, vaultPath, filePath, line, column, reuse, includeVault } = options;
|
||||
const args: string[] = [];
|
||||
|
||||
if (reuse) {
|
||||
args.push("--reuse-window");
|
||||
}
|
||||
|
||||
if (includeVault && vaultPath) {
|
||||
args.push("--folder-uri", pathToFileURL(vaultPath).toString());
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
23
main.ts
Normal file
23
main.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Plugin } from "obsidian";
|
||||
import { DEFAULT_SETTINGS, loadPluginSettings, savePluginSettings, OpenInIDESettingTab, type PluginSettings } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
|
||||
export default class OpenInIDEPlugin extends Plugin {
|
||||
settings!: PluginSettings;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
this.settings = await loadPluginSettings(this, DEFAULT_SETTINGS);
|
||||
|
||||
registerCommands(this);
|
||||
|
||||
this.addSettingTab(new OpenInIDESettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async onunload(): Promise<void> {
|
||||
// Clean up resources that are not already handled by register* helpers.
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await savePluginSettings(this, this.settings);
|
||||
}
|
||||
}
|
||||
11
manifest.json
Normal file
11
manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "open-in-ide",
|
||||
"name": "Open in IDE",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Open the active Obsidian note in Cursor (with room for future IDE support).",
|
||||
"author": "Erik Carlstedt",
|
||||
"authorUrl": "",
|
||||
"fundingUrl": "",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
4388
package-lock.json
generated
Normal file
4388
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "open-in-ide",
|
||||
"version": "0.1.0",
|
||||
"description": "Obsidian plugin that opens the active note in Cursor (and future IDEs).",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "vitest run",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian-plugin",
|
||||
"cursor",
|
||||
"ide",
|
||||
"productivity"
|
||||
],
|
||||
"author": "Erik Carlstedt",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4",
|
||||
"vitest": "0.34.6"
|
||||
}
|
||||
}
|
||||
96
settings.ts
Normal file
96
settings.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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;
|
||||
/** 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. */
|
||||
openVaultBeforeFile: boolean;
|
||||
/** When the CLI is unavailable, try a system-level opener (open/start/xdg-open). */
|
||||
allowSystemFallback: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
cursorExecutablePath: "",
|
||||
reuseExistingWindow: true,
|
||||
openVaultBeforeFile: true,
|
||||
allowSystemFallback: true
|
||||
};
|
||||
|
||||
export interface PluginWithSettings<T extends PluginSettings> extends Plugin {
|
||||
settings: T;
|
||||
saveSettings(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function loadPluginSettings<T extends PluginSettings>(plugin: Plugin, defaults: T): Promise<T> {
|
||||
const stored = await plugin.loadData();
|
||||
return Object.assign({}, defaults, stored ?? {});
|
||||
}
|
||||
|
||||
export async function savePluginSettings<T extends PluginSettings>(plugin: Plugin, settings: T): Promise<void> {
|
||||
await plugin.saveData(settings);
|
||||
}
|
||||
|
||||
export class OpenInIDESettingTab extends PluginSettingTab {
|
||||
constructor(app: App, private readonly plugin: PluginWithSettings<PluginSettings>) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Cursor executable path")
|
||||
.setDesc("Optional absolute path to the Cursor CLI. Leave blank to rely on the PATH lookup.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("/usr/local/bin/cursor")
|
||||
.setValue(this.plugin.settings.cursorExecutablePath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.cursorExecutablePath = 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.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.reuseExistingWindow)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.reuseExistingWindow = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Open vault before file")
|
||||
.setDesc("Open the vault root in Cursor before targeting the active note.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.openVaultBeforeFile)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.openVaultBeforeFile = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Allow system fallback")
|
||||
.setDesc("Use the OS opener if the Cursor CLI cannot be found.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.allowSystemFallback)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.allowSystemFallback = value;
|
||||
await this.plugin.saveSettings();
|
||||
if (!value) {
|
||||
new Notice("System fallback disabled. The command will fail if the Cursor CLI is unavailable.");
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
49
utils/helpers.ts
Normal file
49
utils/helpers.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { App, FileSystemAdapter, Platform, TFile } from "obsidian";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export function isRunningOnDesktop(): boolean {
|
||||
return Platform.isDesktopApp;
|
||||
}
|
||||
|
||||
export function resolveVaultAbsolutePath(app: App, file: TFile): string | null {
|
||||
const adapter = app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sanitizeAbsolutePath(adapter.getFilePath(file.path));
|
||||
}
|
||||
|
||||
export function resolveVaultRoot(app: App): string | null {
|
||||
const adapter = app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = app.vault.getRoot();
|
||||
const rootPath = root.path === "/" ? "" : root.path;
|
||||
const absolute = rootPath ? adapter.getFilePath(rootPath) : adapter.getBasePath();
|
||||
return sanitizeAbsolutePath(absolute);
|
||||
}
|
||||
|
||||
function sanitizeAbsolutePath(value: string): string {
|
||||
if (value.startsWith("file://")) {
|
||||
try {
|
||||
return fileURLToPath(value);
|
||||
} catch (error) {
|
||||
console.warn("[open-in-ide] Failed to convert file URL to path", value, error);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function assertDesktopFeature(app: App): void {
|
||||
if (!isRunningOnDesktop()) {
|
||||
throw new Error("This feature is available on desktop Obsidian only.");
|
||||
}
|
||||
|
||||
const adapter = app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
throw new Error("Cannot resolve absolute paths without the desktop file system adapter.");
|
||||
}
|
||||
}
|
||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue