mirror of
https://github.com/ohm-engineering/obsidian-csharp-interactive.git
synced 2026-07-22 07:24:31 +00:00
First working version. Add workflow to release
This commit is contained in:
parent
91a3e933ee
commit
107f0b26c9
11 changed files with 5787 additions and 5374 deletions
28
.github/workflows/lint.yml
vendored
28
.github/workflows/lint.yml
vendored
|
|
@ -1,28 +0,0 @@
|
|||
name: Node.js build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm run lint
|
||||
|
||||
35
.github/workflows/release.yml
vendored
Normal file
35
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -20,3 +20,6 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
/.tmprepl
|
||||
/.vs
|
||||
repl-help.txt
|
||||
|
|
|
|||
28
CONTRIBUTING.md
Normal file
28
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Contributing
|
||||
|
||||
Thanks for contributing to C# Snippet Runner.
|
||||
|
||||
## Development setup
|
||||
|
||||
1. Install Node.js
|
||||
2. Install dependencies:
|
||||
- `npm install`
|
||||
3. Start dev build/watch:
|
||||
- `npm run dev`
|
||||
|
||||
## Code quality checks
|
||||
|
||||
Before submitting a change, run:
|
||||
|
||||
- `npm run lint`
|
||||
- `npm run build`
|
||||
|
||||
### Testing
|
||||
Test your changes before submitting a pull request. Make sure to use the version of Obsidian specified in the `manifest.json` for compatibility.
|
||||
|
||||
## Pull request guidelines
|
||||
|
||||
- Keep changes focused and minimal.
|
||||
- Follow existing code style and patterns.
|
||||
- Include a description of what changed and why.
|
||||
|
||||
103
README.md
103
README.md
|
|
@ -1,90 +1,35 @@
|
|||
# Obsidian Sample Plugin
|
||||
# C# Snippet Runner
|
||||
|
||||
This is a sample plugin for Obsidian (https://obsidian.md).
|
||||
Obsidian plugin that adds a run button to C# code blocks. It uses CSharpRepl to execute the snippets and displays the output directly in the note.
|
||||
|
||||
This project uses TypeScript to provide type checking and documentation.
|
||||
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
|
||||
## Features
|
||||
|
||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||
- Adds a command "Open modal (simple)" which opens a Modal.
|
||||
- Adds a plugin setting tab to the settings page.
|
||||
- Registers a global click event and output 'click' to the console.
|
||||
- Registers a global interval which logs 'setInterval' to the console.
|
||||
- Supports code blocks containing C# code with tags `csharp`, `cs`, `c#`, `net`, `.net` and `dotnet`
|
||||
- Adds a **Run** button below each block with one of the above tags
|
||||
- Runs snippets using bundled **CSharpRepl** auto-installed with `dotnet tool` into the plugin folder
|
||||
- Supports optional script arguments
|
||||
- Saves output and arguments per snippet in the plugin `responses` folder
|
||||
- Restores saved output/arguments when notes are rendered
|
||||
|
||||
## First time developing plugins?
|
||||
## Development
|
||||
|
||||
Quick starting guide for new plugin devs:
|
||||
|
||||
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
|
||||
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
|
||||
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
|
||||
- Install NodeJS, then run `npm i` in the command line under your repo folder.
|
||||
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
|
||||
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
|
||||
- Reload Obsidian to load the new version of your plugin.
|
||||
- Enable plugin in settings window.
|
||||
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
|
||||
|
||||
## Releasing new releases
|
||||
|
||||
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
|
||||
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
|
||||
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
|
||||
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
|
||||
- Publish the release.
|
||||
|
||||
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
|
||||
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
|
||||
|
||||
## Adding your plugin to the community plugin list
|
||||
|
||||
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
|
||||
- Publish an initial version.
|
||||
- Make sure you have a `README.md` file in the root of your repo.
|
||||
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
|
||||
|
||||
## How to use
|
||||
|
||||
- Clone this repo.
|
||||
- Make sure your NodeJS is at least v16 (`node --version`).
|
||||
- `npm i` or `yarn` to install dependencies.
|
||||
- `npm run dev` to start compilation in watch mode.
|
||||
|
||||
## Manually installing the plugin
|
||||
|
||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||
|
||||
## Improve code quality with eslint
|
||||
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
|
||||
- This project already has eslint preconfigured, you can invoke a check by running`npm run lint`
|
||||
- Together with a custom eslint [plugin](https://github.com/obsidianmd/eslint-plugin) for Obsidan specific code guidelines.
|
||||
- A GitHub action is preconfigured to automatically lint every commit on all branches.
|
||||
|
||||
## Funding URL
|
||||
|
||||
You can include funding URLs where people who use your plugin can financially support it.
|
||||
|
||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": "https://buymeacoffee.com"
|
||||
}
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
If you have multiple URLs, you can also do:
|
||||
## Build
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": {
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
||||
"GitHub Sponsor": "https://github.com/sponsors",
|
||||
"Patreon": "https://www.patreon.com/"
|
||||
}
|
||||
}
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
## Manual installation
|
||||
|
||||
See https://docs.obsidian.md
|
||||
Copy these files to:
|
||||
|
||||
`<Vault>/.obsidian/plugins/csharp-snippet-runner/`
|
||||
|
||||
- `main.js`
|
||||
- `manifest.json`
|
||||
- `styles.css`
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"id": "obsidian-csharp-snippet-runner",
|
||||
"name": "C# Snippet Runner",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Demonstrates some of the capabilities of the Obsidian API.",
|
||||
"author": "Obsidian",
|
||||
"authorUrl": "https://obsidian.md",
|
||||
"fundingUrl": "https://obsidian.md/pricing",
|
||||
"minAppVersion": "1.12.7",
|
||||
"description": "Run C# code blocks in Obsidian using an auto-installed CSharpRepl runtime.",
|
||||
"author": "OHM Engineering",
|
||||
"authorUrl": "https://github.com/OHM-Engineering",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
10324
package-lock.json
generated
10324
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"name": "obsidian-csharp-snippet-runner",
|
||||
"version": "1.0.0",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"description": "Obsidian plugin to run C# code blocks with CSharpRepl",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -13,15 +13,16 @@
|
|||
"keywords": [],
|
||||
"license": "0-BSD",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.1",
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.1.9",
|
||||
"globals": "14.0.0",
|
||||
"jiti": "2.6.1",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"@eslint/js": "9.30.1",
|
||||
"jiti": "2.6.1"
|
||||
"typescript-eslint": "8.35.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"obsidian": "latest"
|
||||
|
|
|
|||
533
src/main.ts
533
src/main.ts
|
|
@ -1,99 +1,490 @@
|
|||
import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian';
|
||||
import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings";
|
||||
import { FileSystemAdapter, loadPrism, MarkdownPostProcessorContext, Notice, Platform, Plugin } from 'obsidian';
|
||||
import { CSharpSnippetSettingTab, CSharpSnippetSettings, DEFAULT_SETTINGS } from './settings';
|
||||
|
||||
// Remember to rename these classes and interfaces!
|
||||
interface NodeChildProcess {
|
||||
stdout: { on: (event: 'data', cb: (chunk: unknown) => void) => void };
|
||||
stderr: { on: (event: 'data', cb: (chunk: unknown) => void) => void };
|
||||
stdin: { write: (chunk: string) => void; end: () => void };
|
||||
on: (event: 'error' | 'close', cb: (value: unknown) => void) => void;
|
||||
kill: () => void;
|
||||
}
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MyPluginSettings;
|
||||
interface NodeChildProcessModule {
|
||||
spawn: (command: string, argv: string[], options: { windowsHide: boolean; stdio: string[] }) => NodeChildProcess;
|
||||
}
|
||||
|
||||
interface NodeFsModule {
|
||||
existsSync: (path: string) => boolean;
|
||||
}
|
||||
|
||||
interface NodeFsPromisesModule {
|
||||
mkdir: (targetPath: string, options: { recursive: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
const LOG_PREFIX = '[csharp-snippet-runner]';
|
||||
const RESPONSES_FOLDER_NAME = 'responses';
|
||||
const RUNTIME_FOLDER_NAME = 'runtime';
|
||||
const REPL_TOOL_FOLDER_NAME = 'csharprepl';
|
||||
|
||||
interface ReplExecutionError extends Error {
|
||||
code?: number | string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
export default class CSharpSnippetRunnerPlugin extends Plugin {
|
||||
settings: CSharpSnippetSettings;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new CSharpSnippetSettingTab(this.app, this));
|
||||
|
||||
// This creates an icon in the left ribbon.
|
||||
this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => {
|
||||
// Called when the user clicks the icon.
|
||||
new Notice('This is a notice!');
|
||||
// We want to support multiple tags for C# code blocks, which is why we register multiple processors
|
||||
this.registerMarkdownCodeBlockProcessor('csharp', async (source, element, context) => {
|
||||
await this.renderRunnableSnippet(source, element, context);
|
||||
});
|
||||
|
||||
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
||||
const statusBarItemEl = this.addStatusBarItem();
|
||||
statusBarItemEl.setText('Status bar text');
|
||||
|
||||
// This adds a simple command that can be triggered anywhere
|
||||
this.addCommand({
|
||||
id: 'open-modal-simple',
|
||||
name: 'Open modal (simple)',
|
||||
callback: () => {
|
||||
new SampleModal(this.app).open();
|
||||
}
|
||||
});
|
||||
// This adds an editor command that can perform some operation on the current editor instance
|
||||
this.addCommand({
|
||||
id: 'replace-selected',
|
||||
name: 'Replace selected content',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
editor.replaceSelection('Sample editor command');
|
||||
}
|
||||
});
|
||||
// This adds a complex command that can check whether the current state of the app allows execution of the command
|
||||
this.addCommand({
|
||||
id: 'open-modal-complex',
|
||||
name: 'Open modal (complex)',
|
||||
checkCallback: (checking: boolean) => {
|
||||
// Conditions to check
|
||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (markdownView) {
|
||||
// If checking is true, we're simply "checking" if the command can be run.
|
||||
// If checking is false, then we want to actually perform the operation.
|
||||
if (!checking) {
|
||||
new SampleModal(this.app).open();
|
||||
}
|
||||
|
||||
// This command will only show up in Command Palette when the check function returns true
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.registerMarkdownCodeBlockProcessor('cs', async (source, element, context) => {
|
||||
await this.renderRunnableSnippet(source, element, context);
|
||||
});
|
||||
|
||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||
this.addSettingTab(new SampleSettingTab(this.app, this));
|
||||
|
||||
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
||||
// Using this function will automatically remove the event listener when this plugin is disabled.
|
||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
||||
new Notice("Click");
|
||||
this.registerMarkdownCodeBlockProcessor('c#', async (source, element, context) => {
|
||||
await this.renderRunnableSnippet(source, element, context);
|
||||
});
|
||||
|
||||
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
||||
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
||||
this.registerMarkdownCodeBlockProcessor('.net', async (source, element, context) => {
|
||||
await this.renderRunnableSnippet(source, element, context);
|
||||
});
|
||||
|
||||
this.registerMarkdownCodeBlockProcessor('net', async (source, element, context) => {
|
||||
await this.renderRunnableSnippet(source, element, context);
|
||||
});
|
||||
|
||||
|
||||
this.registerMarkdownCodeBlockProcessor('dotnet', async (source, element, context) => {
|
||||
await this.renderRunnableSnippet(source, element, context);
|
||||
});
|
||||
}
|
||||
|
||||
onunload() {
|
||||
}
|
||||
|
||||
// Settings are used to specify the timeout for running snippets
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<MyPluginSettings>);
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<CSharpSnippetSettings>);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
// This function adds all of our custom UI elements to the code snippet block
|
||||
private async renderRunnableSnippet(source: string, element: HTMLElement, context: MarkdownPostProcessorContext): Promise<void> {
|
||||
const codeElement = document.createElement('pre');
|
||||
codeElement.addClass('language-csharp');
|
||||
const codeInner = document.createElement('code');
|
||||
codeInner.addClass('language-csharp');
|
||||
codeInner.textContent = source;
|
||||
codeElement.appendChild(codeInner);
|
||||
await this.highlightCode(codeInner);
|
||||
|
||||
const runnerContainer = document.createElement('div');
|
||||
runnerContainer.className = 'csharp-runner-container';
|
||||
|
||||
const runButton = document.createElement('button');
|
||||
runButton.className = 'csharp-runner-button';
|
||||
runButton.textContent = 'Run snippet';
|
||||
|
||||
const argsElement = document.createElement('textarea');
|
||||
argsElement.className = 'csharp-runner-input';
|
||||
argsElement.placeholder = 'Optional script args. Access them using the args array.';
|
||||
|
||||
const argsDetails = document.createElement('details');
|
||||
argsDetails.className = 'csharp-runner-details';
|
||||
const argsSummary = document.createElement('summary');
|
||||
argsSummary.textContent = 'Arguments';
|
||||
argsDetails.appendChild(argsSummary);
|
||||
argsDetails.appendChild(argsElement);
|
||||
|
||||
// Output element is hidden until we have something to display
|
||||
const outputElement = document.createElement('pre');
|
||||
outputElement.className = 'csharp-runner-output';
|
||||
outputElement.hidden = true;
|
||||
|
||||
const responseOutputPath = await this.getBlockResponseOutputPath(source, context, element);
|
||||
const responseArgsPath = responseOutputPath
|
||||
? this.getBlockArgsPathFromOutputPath(responseOutputPath)
|
||||
: null;
|
||||
|
||||
// We store the args that are used so they are persisted when the note is closed and reopened
|
||||
if (responseArgsPath) {
|
||||
const existingArgs = await this.readSavedResponse(responseArgsPath);
|
||||
if (existingArgs !== null) {
|
||||
argsElement.value = existingArgs;
|
||||
}
|
||||
|
||||
// Save args on input with a debounce to avoid excessive writes
|
||||
let argsSaveTimeout: number | null = null;
|
||||
argsElement.addEventListener('input', () => {
|
||||
if (argsSaveTimeout !== null) {
|
||||
window.clearTimeout(argsSaveTimeout);
|
||||
}
|
||||
|
||||
argsSaveTimeout = window.setTimeout(() => {
|
||||
void this.saveResponse(responseArgsPath, argsElement.value);
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
// We'll show any previously aquired output when the snippet is rendered
|
||||
if (responseOutputPath) {
|
||||
const existingOutput = await this.readSavedResponse(responseOutputPath);
|
||||
if (existingOutput !== null) {
|
||||
outputElement.hidden = false;
|
||||
outputElement.textContent = existingOutput;
|
||||
}
|
||||
}
|
||||
|
||||
// The main event handler for running the snippet when the button is clicked
|
||||
runButton.addEventListener('click', () => {
|
||||
void this.runSnippet(source, argsElement, outputElement, responseOutputPath, responseArgsPath, runButton);
|
||||
});
|
||||
|
||||
element.appendChild(codeElement);
|
||||
runnerContainer.appendChild(argsDetails);
|
||||
runnerContainer.appendChild(runButton);
|
||||
runnerContainer.appendChild(outputElement);
|
||||
element.appendChild(runnerContainer);
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
let {contentEl} = this;
|
||||
contentEl.setText('Woah!');
|
||||
// This function handles the UI state when running a snippet
|
||||
private async runSnippet(
|
||||
source: string,
|
||||
argsElement: HTMLTextAreaElement,
|
||||
outputElement: HTMLElement,
|
||||
responseOutputPath: string | null,
|
||||
responseArgsPath: string | null,
|
||||
runButton: HTMLButtonElement,
|
||||
): Promise<void> {
|
||||
runButton.disabled = true;
|
||||
argsElement.disabled = true;
|
||||
runButton.textContent = 'Running...';
|
||||
outputElement.hidden = false;
|
||||
outputElement.textContent = 'Running snippet...';
|
||||
|
||||
// We run the snippet in a try/finally to ensure the UI is re-enabled even if execution fails
|
||||
try {
|
||||
const result = await this.executeSnippet(source, argsElement.value);
|
||||
outputElement.textContent = result;
|
||||
if (responseOutputPath) {
|
||||
await this.saveResponse(responseOutputPath, result);
|
||||
}
|
||||
if (responseArgsPath) {
|
||||
await this.saveResponse(responseArgsPath, argsElement.value);
|
||||
}
|
||||
} finally {
|
||||
runButton.disabled = false;
|
||||
argsElement.disabled = false;
|
||||
runButton.textContent = 'Run snippet';
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const {contentEl} = this;
|
||||
contentEl.empty();
|
||||
// This function prepares the snippet and arguments for execution
|
||||
private async executeSnippet(snippet: string, argsInput: string): Promise<string> {
|
||||
if (!Platform.isDesktopApp) {
|
||||
return 'Running snippets is only supported in desktop Obsidian.';
|
||||
}
|
||||
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
return 'Vault filesystem is unavailable. Cannot run C# snippets.';
|
||||
}
|
||||
|
||||
const basePath = adapter.getBasePath();
|
||||
console.debug(`${LOG_PREFIX} Vault base path:`, basePath);
|
||||
|
||||
let replPath: string;
|
||||
try {
|
||||
replPath = await this.resolveCSharpReplPath(adapter);
|
||||
console.debug(`${LOG_PREFIX} Using CSharpRepl executable:`, replPath);
|
||||
} catch {
|
||||
return [
|
||||
'Could not prepare CSharpRepl.',
|
||||
'Make sure .NET SDK is installed and available as dotnet.',
|
||||
'Check console logs for install errors.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Parsing args is quite simple. Space is a seperator and we guard agains whitespace
|
||||
// This does have the downside that we don't support args with spaces.
|
||||
const scriptArgs = argsInput.split(' ')
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
|
||||
const scriptContent = this.buildScript(snippet, scriptArgs);
|
||||
const replArgs = ['--streamPipedInput'];
|
||||
console.debug(`${LOG_PREFIX} Running CSharpRepl with piped script input.`);
|
||||
console.debug(`${LOG_PREFIX} CSharpRepl args:`, replArgs);
|
||||
console.debug(`${LOG_PREFIX} Parsed script arg count:`, scriptArgs.length);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await this.runRepl(replPath, replArgs, scriptContent);
|
||||
|
||||
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
||||
console.debug(`${LOG_PREFIX} Snippet execution completed.`);
|
||||
return output.length > 0 ? output : '(No output)';
|
||||
} catch (error: unknown) {
|
||||
const executionError = error as ReplExecutionError;
|
||||
if (executionError.code === 'ENOENT') {
|
||||
return 'CSharpRepl executable is missing. Ensure dotnet tool install completed successfully.';
|
||||
}
|
||||
const output = [executionError.stdout, executionError.stderr, executionError.message]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
console.error(`${LOG_PREFIX} Snippet execution failed:`, executionError);
|
||||
|
||||
new Notice('C# snippet execution failed.');
|
||||
return output.length > 0 ? output : 'Snippet execution failed.';
|
||||
}
|
||||
}
|
||||
|
||||
// The function that calls CSharpRepl and captures the output
|
||||
private runRepl(replPath: string, args: string[], scriptContent: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess = this.getDesktopNodeModule<NodeChildProcessModule>('child_process');
|
||||
const child = childProcess.spawn(replPath, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
// Make sure we kill the process if it exceeds the specified timeout
|
||||
const timeoutMs = this.settings.executionTimeoutMs;
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
console.debug(`${LOG_PREFIX} CSharpRepl timeout (${timeoutMs}ms) reached; terminating process.`);
|
||||
child.kill();
|
||||
}, timeoutMs);
|
||||
|
||||
// Capture the output
|
||||
child.stdout.on('data', (chunk: unknown) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: unknown) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
|
||||
// Handle exit based on exit code/timeout
|
||||
child.on('close', (codeValue) => {
|
||||
const code = typeof codeValue === 'number' ? codeValue : -1;
|
||||
clearTimeout(timeoutHandle);
|
||||
|
||||
if (timedOut) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
const executionError = new Error(`CSharpRepl exited with code ${String(code)}`) as ReplExecutionError;
|
||||
executionError.code = code ?? undefined;
|
||||
executionError.stdout = stdout;
|
||||
executionError.stderr = stderr;
|
||||
reject(executionError);
|
||||
});
|
||||
|
||||
child.stdin.write(scriptContent);
|
||||
|
||||
// CSharpRepl expects a newline at the end of the script
|
||||
if (!scriptContent.endsWith('\n')) {
|
||||
child.stdin.write('\n');
|
||||
}
|
||||
child.stdin.end();
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// While testing I found that passing args as specified by CSharpRepl (using -- as seperator) did not work
|
||||
// We'll manually add the args by creating a new array ourselves and prepending it to the snippet
|
||||
private buildScript(snippet: string, scriptArgs: string[]): string {
|
||||
if (scriptArgs.length === 0) {
|
||||
return snippet;
|
||||
}
|
||||
|
||||
const argsBootstrap = `args = new string[] { ${scriptArgs
|
||||
.map((item) => JSON.stringify(item))
|
||||
.join(', ')} };`;
|
||||
|
||||
return [argsBootstrap, snippet].join('\n');
|
||||
}
|
||||
|
||||
// This function checks if CSharpRepl is installed. If not we install it using dotnet tool install and return the path
|
||||
private async resolveCSharpReplPath(adapter: FileSystemAdapter): Promise<string> {
|
||||
const toolPath = this.getBundledReplToolPath(adapter);
|
||||
const executablePath = this.getToolExecutablePath(toolPath);
|
||||
if (await this.pathExists(executablePath)) {
|
||||
return executablePath;
|
||||
}
|
||||
|
||||
const fsPromises = this.getDesktopNodeModule<NodeFsPromisesModule>('fs/promises');
|
||||
await fsPromises.mkdir(toolPath, { recursive: true });
|
||||
console.debug(`${LOG_PREFIX} Installing CSharpRepl into:`, toolPath);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const childProcess = this.getDesktopNodeModule<NodeChildProcessModule>('child_process');
|
||||
const child = childProcess.spawn('dotnet', ['tool', 'install', 'csharprepl', '--tool-path', toolPath], {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', () => undefined);
|
||||
child.stderr.on('data', (chunk: unknown) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
|
||||
child.on('close', (codeValue: unknown) => {
|
||||
const code = typeof codeValue === 'number' ? codeValue : -1;
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `dotnet exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
if (await this.pathExists(executablePath)) {
|
||||
return executablePath;
|
||||
}
|
||||
|
||||
throw new Error('Bundled CSharpRepl not found after installation');
|
||||
}
|
||||
|
||||
private async pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fs = this.getDesktopNodeModule<NodeFsModule>('fs');
|
||||
return fs.existsSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Using the snippet's hash, we can create a unique path for the snippet's output
|
||||
private async getBlockResponseOutputPath(source: string, context: MarkdownPostProcessorContext, element: HTMLElement): Promise<string | null> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const responsesFolder = this.joinVaultPath(this.app.vault.configDir, 'plugins', this.manifest.id, RESPONSES_FOLDER_NAME);
|
||||
if (!(await adapter.exists(responsesFolder))) {
|
||||
await adapter.mkdir(responsesFolder);
|
||||
}
|
||||
|
||||
const sourcePath = typeof context?.sourcePath === 'string' ? context.sourcePath : 'unknown';
|
||||
const lineStart = typeof context?.getSectionInfo === 'function'
|
||||
? context.getSectionInfo(element)?.lineStart ?? -1
|
||||
: -1;
|
||||
const id = await this.hashText(`${sourcePath}:${lineStart}:${source}`);
|
||||
|
||||
return this.joinVaultPath(responsesFolder, `${id}.txt`);
|
||||
}
|
||||
|
||||
|
||||
private async readSavedResponse(responsePath: string): Promise<string | null> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
try {
|
||||
return await adapter.read(responsePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveResponse(responsePath: string, value: string): Promise<void> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
try {
|
||||
await adapter.write(responsePath, value);
|
||||
console.debug(`${LOG_PREFIX} Saved response output:`, responsePath);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} Failed to save response output:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockArgsPathFromOutputPath(outputPath: string): string {
|
||||
return outputPath.endsWith('.txt')
|
||||
? `${outputPath.slice(0, -4)}.args.txt`
|
||||
: `${outputPath}.args.txt`;
|
||||
}
|
||||
|
||||
// Use Prism to highlight the code block like Obsidian does.
|
||||
// We need to do this manually since we're basically replacing the code block with our own version.
|
||||
private async highlightCode(codeElement: HTMLElement): Promise<void> {
|
||||
try {
|
||||
const prism = await loadPrism() as { highlightElement?: (element: HTMLElement) => void };
|
||||
if (typeof prism.highlightElement === 'function') {
|
||||
prism.highlightElement(codeElement);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`${LOG_PREFIX} Failed to highlight code block:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private getBundledReplToolPath(adapter: FileSystemAdapter): string {
|
||||
return this.joinSystemPath(adapter.getBasePath(), this.app.vault.configDir, 'plugins', this.manifest.id, RUNTIME_FOLDER_NAME, REPL_TOOL_FOLDER_NAME);
|
||||
}
|
||||
|
||||
private getToolExecutablePath(toolPath: string): string {
|
||||
return Platform.isWin
|
||||
? this.joinSystemPath(toolPath, 'csharprepl.exe')
|
||||
: this.joinSystemPath(toolPath, 'csharprepl');
|
||||
}
|
||||
|
||||
// Guard against environments where Node require is not available
|
||||
private getDesktopNodeModule<TModule>(moduleName: string): TModule {
|
||||
if (!Platform.isDesktopApp) {
|
||||
throw new Error('Node modules are only available on desktop.');
|
||||
}
|
||||
|
||||
const electronWindow = window as Window & { require?: (moduleName: string) => unknown };
|
||||
if (typeof electronWindow.require !== 'function') {
|
||||
throw new Error('Node require is unavailable in this environment.');
|
||||
}
|
||||
|
||||
return electronWindow.require(moduleName) as TModule;
|
||||
}
|
||||
|
||||
private joinVaultPath(...parts: string[]): string {
|
||||
// Vault adapter paths are POSIX-style (forward slashes), regardless of OS.
|
||||
// Normalize all separators and collapse duplicate slashes.
|
||||
return parts.filter(Boolean).join('/').replace(/\/+/g, '/').replace(/\/\//g, '/');
|
||||
}
|
||||
|
||||
private joinSystemPath(...parts: string[]): string {
|
||||
// Runtime executable paths are OS-native filesystem paths.
|
||||
// Use '\\' on Windows and '/' elsewhere, then normalize mixed separators.
|
||||
const separator = Platform.isWin ? '\\' : '/';
|
||||
return parts
|
||||
.filter(Boolean)
|
||||
.join(separator)
|
||||
.replace(/[\\/]+/g, separator);
|
||||
}
|
||||
|
||||
private async hashText(value: string): Promise<string> {
|
||||
const encoded = new TextEncoder().encode(value);
|
||||
const digest = await crypto.subtle.digest('SHA-256', encoded);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,42 @@
|
|||
import {App, PluginSettingTab, Setting} from "obsidian";
|
||||
import MyPlugin from "./main";
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import CSharpSnippetRunnerPlugin from './main';
|
||||
|
||||
export interface MyPluginSettings {
|
||||
mySetting: string;
|
||||
export interface CSharpSnippetSettings {
|
||||
executionTimeoutMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MyPluginSettings = {
|
||||
mySetting: 'default'
|
||||
}
|
||||
export const DEFAULT_SETTINGS: CSharpSnippetSettings = {
|
||||
executionTimeoutMs: 3000,
|
||||
};
|
||||
|
||||
export class SampleSettingTab extends PluginSettingTab {
|
||||
plugin: MyPlugin;
|
||||
export class CSharpSnippetSettingTab extends PluginSettingTab {
|
||||
plugin: CSharpSnippetRunnerPlugin;
|
||||
|
||||
constructor(app: App, plugin: MyPlugin) {
|
||||
constructor(app: App, plugin: CSharpSnippetRunnerPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const {containerEl} = this;
|
||||
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Settings #1')
|
||||
.setDesc('It\'s a secret')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter your secret')
|
||||
.setValue(this.plugin.settings.mySetting)
|
||||
.setName('C# snippet runner')
|
||||
.setDesc('Code blocks tagged with cs, csharp, or c# include a run snippet button in preview mode.');
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Execution timeout (ms)')
|
||||
.setDesc('Maximum time to wait before stopping execution and returning captured output.')
|
||||
.addText((text) => text
|
||||
.setPlaceholder('3000')
|
||||
.setValue(String(this.plugin.settings.executionTimeoutMs))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.mySetting = value;
|
||||
await this.plugin.saveSettings();
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
this.plugin.settings.executionTimeoutMs = parsed;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
styles.css
35
styles.css
|
|
@ -1,8 +1,33 @@
|
|||
/*
|
||||
.csharp-runner-container {
|
||||
margin: 0.5rem 0 1.25rem;
|
||||
}
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
.csharp-runner-button {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
.csharp-runner-input {
|
||||
width: 100%;
|
||||
min-height: 4.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
*/
|
||||
.csharp-runner-details {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.csharp-runner-details > summary {
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.csharp-runner-output {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue