Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
608b3820b1 |
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es6": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:import/recommended",
|
||||||
|
"plugin:import/electron",
|
||||||
|
"plugin:import/typescript"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser"
|
||||||
|
}
|
||||||
11
README.md
11
README.md
|
|
@ -1,11 +0,0 @@
|
||||||
# Miller code editor
|
|
||||||
|
|
||||||
This is a code editor/IDE based on CodeMirror.
|
|
||||||
Its primary goal is to provide the user with a stack/column-based navigation history, making codebase navigation easier without getting lost.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- [x] Basic file editing
|
|
||||||
- [x] Terminal integration
|
|
||||||
- [x] File watching
|
|
||||||
- [ ] Warn on exit when there are unsaved files
|
|
||||||
- [ ] LSP support
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
import globals from "globals";
|
|
||||||
import js from "@eslint/js";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores([".vite/"]),
|
|
||||||
{
|
|
||||||
// files: ["src/*.{js,mjs,cjs,ts,mts,cts}"],
|
|
||||||
plugins: { js },
|
|
||||||
extends: ["js/recommended"],
|
|
||||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
|
||||||
},
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
]);
|
|
||||||
115
forge.config.ts
115
forge.config.ts
|
|
@ -1,66 +1,59 @@
|
||||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||||
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
|
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||||
import { MakerZIP } from "@electron-forge/maker-zip";
|
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||||
import { MakerDeb } from "@electron-forge/maker-deb";
|
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||||
import { MakerRpm } from "@electron-forge/maker-rpm";
|
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||||
import { MakerFlatpak } from "@electron-forge/maker-flatpak";
|
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||||
import { FusesPlugin } from "@electron-forge/plugin-fuses";
|
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
|
||||||
|
|
||||||
const config: ForgeConfig = {
|
const config: ForgeConfig = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
asar: true,
|
asar: true,
|
||||||
icon: "res/icon",
|
},
|
||||||
extraResource: "res/icon.png",
|
rebuildConfig: {},
|
||||||
ignore: ["dist", "src", "res",], // Workaround for https://github.com/electron/forge/issues/3738
|
makers: [
|
||||||
executableName: "miller",
|
new MakerSquirrel({}),
|
||||||
appCategoryType: 'public.app-category.developer-tools',
|
new MakerZIP({}),
|
||||||
name: "Miller",
|
// new MakerRpm({}),
|
||||||
},
|
// new MakerDeb({}),
|
||||||
rebuildConfig: {},
|
],
|
||||||
makers: [
|
plugins: [
|
||||||
new MakerSquirrel({}),
|
new VitePlugin({
|
||||||
new MakerZIP({}),
|
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
|
||||||
new MakerRpm({ options: { icon: "res/icon.png", categories: ["Development"] } }),
|
// If you are familiar with Vite configuration, it will look really familiar.
|
||||||
new MakerDeb({ options: { icon: "res/icon.png", categories: ["Development"] } }),
|
build: [
|
||||||
],
|
{
|
||||||
plugins: [
|
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
|
||||||
new VitePlugin({
|
entry: 'src/main/main.ts',
|
||||||
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
|
config: 'vite.config.ts',
|
||||||
// If you are familiar with Vite configuration, it will look really familiar.
|
target: 'main',
|
||||||
build: [
|
},
|
||||||
{
|
{
|
||||||
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
|
entry: 'src/preload.ts',
|
||||||
entry: "src/main/main.ts",
|
config: 'vite.config.ts',
|
||||||
config: "vite.config.mts",
|
target: 'preload',
|
||||||
target: "main",
|
},
|
||||||
},
|
],
|
||||||
{
|
renderer: [
|
||||||
entry: "src/preload.ts",
|
{
|
||||||
config: "vite.config.mts",
|
name: 'main_window',
|
||||||
target: "preload",
|
config: 'vite.config.ts',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
renderer: [
|
}),
|
||||||
{
|
// Fuses are used to enable/disable various Electron functionality
|
||||||
name: "main_window",
|
// at package time, before code signing the application
|
||||||
config: "vite.config.mts",
|
new FusesPlugin({
|
||||||
},
|
version: FuseVersion.V1,
|
||||||
],
|
[FuseV1Options.RunAsNode]: false,
|
||||||
}),
|
[FuseV1Options.EnableCookieEncryption]: true,
|
||||||
// Fuses are used to enable/disable various Electron functionality
|
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||||
// at package time, before code signing the application
|
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||||
new FusesPlugin({
|
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||||
version: FuseVersion.V1,
|
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||||
[FuseV1Options.RunAsNode]: false,
|
}),
|
||||||
[FuseV1Options.EnableCookieEncryption]: true,
|
],
|
||||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
|
||||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
|
||||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
|
||||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
13
index.html
13
index.html
|
|
@ -3,11 +3,16 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Miller code editor</title>
|
<title>Miller code editor</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<link href="/src/app/index.css" rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="dark:scheme-dark dark:bg-neutral-800 dark:text-gray-100">
|
<body>
|
||||||
|
<header>
|
||||||
|
Miller code editor - welcome!
|
||||||
|
<button id="addEditor">Add Editor</button>
|
||||||
|
</header>
|
||||||
|
<aside>
|
||||||
|
<nav></nav>
|
||||||
|
</aside>
|
||||||
|
<main id="editorGrid"></main>
|
||||||
<script type="module" src="/src/app/renderer.ts"></script>
|
<script type="module" src="/src/app/renderer.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
106
package.json
106
package.json
|
|
@ -1,62 +1,48 @@
|
||||||
{
|
{
|
||||||
"name": "miller",
|
"name": "miller",
|
||||||
"productName": "miller",
|
"productName": "miller",
|
||||||
"version": "0.2.4",
|
"version": "1.0.0",
|
||||||
"description": "Column-based code editor",
|
"description": "My Electron application description",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"start": "electron-forge start",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
"lint": "eslint --ext .ts,.tsx .",
|
"lint": "eslint --ext .ts,.tsx .",
|
||||||
"format": "prettier --write \"src/**/*.{js,ts,tsx,css,html}\""
|
"format": "prettier --write \"src/**/*.{js,ts,tsx,css,html}\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Quinten Kock",
|
"name": "Quinten Kock",
|
||||||
"email": "quinten@quinten.space"
|
"email": "quinten@quinten.space"
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/language-data": "^6.5.2",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@electron-forge/cli": "^7.8.3",
|
||||||
"@codemirror/lsp-client": "^6.2.0",
|
"@electron-forge/maker-deb": "^7.8.3",
|
||||||
"@electron-forge/cli": "^7.10.2",
|
"@electron-forge/maker-rpm": "^7.8.3",
|
||||||
"@electron-forge/maker-deb": "^7.10.2",
|
"@electron-forge/maker-squirrel": "^7.8.3",
|
||||||
"@electron-forge/maker-rpm": "^7.10.2",
|
"@electron-forge/maker-zip": "^7.8.3",
|
||||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
"@electron-forge/plugin-auto-unpack-natives": "^7.8.3",
|
||||||
"@electron-forge/maker-zip": "^7.10.2",
|
"@electron-forge/plugin-fuses": "^7.8.3",
|
||||||
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
|
"@electron-forge/plugin-vite": "^7.8.3",
|
||||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
"@electron/fuses": "^1.8.0",
|
||||||
"@electron-forge/plugin-vite": "^7.10.2",
|
"@types/electron-squirrel-startup": "^1.0.2",
|
||||||
"@electron/fuses": "^1.8.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@eslint/compat": "^1.4.1",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"codemirror": "^6.0.2",
|
||||||
"@eslint/js": "^9.39.1",
|
"electron": "37.2.6",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"eslint": "^8.57.1",
|
||||||
"@types/chokidar": "^1.7.5",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"@types/electron-squirrel-startup": "^1.0.2",
|
"prettier": "^3.6.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
"typescript": "~4.5.4",
|
||||||
"@typescript-eslint/parser": "^8.46.3",
|
"vanjs-core": "^1.5.5",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"vanjs-ext": "^0.6.3",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"vite": "^7.1.2"
|
||||||
"codemirror": "^6.0.2",
|
},
|
||||||
"electron": "39.2.4",
|
"dependencies": {
|
||||||
"eslint": "^9.39.1",
|
"electron-squirrel-startup": "^1.0.1"
|
||||||
"eslint-plugin-import": "^2.32.0",
|
}
|
||||||
"globals": "^16.5.0",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"tailwindcss": "^4.1.17",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.46.3",
|
|
||||||
"vanjs-core": "^1.6.0",
|
|
||||||
"vanjs-ext": "^0.6.3",
|
|
||||||
"vite": "^7.2.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"chokidar": "^5.0.0",
|
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
|
||||||
"node-pty": "^1.1.0-beta39"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
res/icon.icns
BIN
res/icon.icns
Binary file not shown.
BIN
res/icon.ico
BIN
res/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 KiB |
BIN
res/icon.png
BIN
res/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -1,79 +0,0 @@
|
||||||
export type KeyHandler = (e: KeyboardEvent) => void;
|
|
||||||
|
|
||||||
function canonicalizeEventKey(e: KeyboardEvent) {
|
|
||||||
const mods = [] as string[];
|
|
||||||
if (e.ctrlKey) mods.push("Ctrl");
|
|
||||||
if (e.altKey) mods.push("Alt");
|
|
||||||
if (e.shiftKey) mods.push("Shift");
|
|
||||||
if (e.metaKey) mods.push("Meta");
|
|
||||||
let k = e.key;
|
|
||||||
if (k.length === 1) k = k.toLowerCase();
|
|
||||||
mods.push(k);
|
|
||||||
return mods.join("-");
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class Displayable {
|
|
||||||
protected deleteFn?: () => void;
|
|
||||||
private shortcuts = new Map<string, KeyHandler>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Attempt to install handlers shortly after construction.
|
|
||||||
// If `dom` is not available yet, retry a few times.
|
|
||||||
setTimeout(() => this.installHandlers(0), 0);
|
|
||||||
|
|
||||||
// Add general shortcuts
|
|
||||||
this.addShortcut("Alt-w", () => this.close());
|
|
||||||
this.addShortcut("Alt--", () => this.changeWidth(-100));
|
|
||||||
this.addShortcut("Alt-=", () => this.changeWidth(100));
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeleteFunction(fn: () => void) {
|
|
||||||
this.deleteFn = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
addShortcut(k: string, handler: KeyHandler) {
|
|
||||||
this.shortcuts.set(k, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleKeyEvent(e: KeyboardEvent) {
|
|
||||||
const k = canonicalizeEventKey(e);
|
|
||||||
const h = this.shortcuts.get(k);
|
|
||||||
if (h) {
|
|
||||||
if (e.type == "keydown") h(e);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
changeWidth(increment: number) {
|
|
||||||
const w = parseInt(window.getComputedStyle(this.dom).width, 10);
|
|
||||||
this.dom.style.width = w + increment + "px";
|
|
||||||
this.dom.scrollIntoView();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private installHandlers(attempt: number) {
|
|
||||||
try {
|
|
||||||
const root = this.dom;
|
|
||||||
if (!root) throw new Error("no dom");
|
|
||||||
|
|
||||||
const keyHandler = (e: KeyboardEvent) => this.handleKeyEvent(e);
|
|
||||||
root.addEventListener("keydown", keyHandler, { capture: true });
|
|
||||||
root.addEventListener("keyup", keyHandler, { capture: true });
|
|
||||||
|
|
||||||
root.addEventListener("focusin", () => {
|
|
||||||
this.dom.scrollIntoView({ behavior: "smooth" });
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (attempt < 5) {
|
|
||||||
setTimeout(() => this.installHandlers(attempt + 1), 50);
|
|
||||||
} else {
|
|
||||||
console.error("Failed to install key handlers:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract focus(): void;
|
|
||||||
abstract title(): string;
|
|
||||||
abstract close(): boolean;
|
|
||||||
abstract get dom(): HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
@ -1,226 +1,36 @@
|
||||||
import {
|
|
||||||
Transaction,
|
|
||||||
Compartment,
|
|
||||||
Extension,
|
|
||||||
StateEffect,
|
|
||||||
StateField,
|
|
||||||
EditorState,
|
|
||||||
} from "@codemirror/state";
|
|
||||||
import {
|
|
||||||
EditorView,
|
|
||||||
keymap,
|
|
||||||
lineNumbers,
|
|
||||||
highlightSpecialChars,
|
|
||||||
highlightActiveLine,
|
|
||||||
highlightActiveLineGutter,
|
|
||||||
drawSelection,
|
|
||||||
dropCursor,
|
|
||||||
rectangularSelection,
|
|
||||||
crosshairCursor,
|
|
||||||
showPanel,
|
|
||||||
} from "@codemirror/view";
|
|
||||||
import { defaultKeymap, undo, redo, indentWithTab } from "@codemirror/commands";
|
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
|
||||||
import {
|
|
||||||
LanguageDescription,
|
|
||||||
foldGutter,
|
|
||||||
indentOnInput,
|
|
||||||
bracketMatching,
|
|
||||||
foldKeymap,
|
|
||||||
indentUnit,
|
|
||||||
} from "@codemirror/language";
|
|
||||||
import { languages } from "@codemirror/language-data";
|
|
||||||
import { autocompletion, closeBrackets } from "@codemirror/autocomplete";
|
|
||||||
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
|
|
||||||
import { lintGutter, lintKeymap } from "@codemirror/lint";
|
|
||||||
import van from "vanjs-core";
|
import van from "vanjs-core";
|
||||||
import { Displayable } from "./displayable";
|
import * as vanX from "vanjs-ext";
|
||||||
import { createLspExtension } from "./lsp";
|
const v = van.tags;
|
||||||
|
|
||||||
import { OpenFile } from "./filestate";
|
import { basicSetup } from "codemirror";
|
||||||
import {
|
import { EditorView } from "@codemirror/view";
|
||||||
findReferencesKeymap,
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
formatKeymap,
|
|
||||||
renameKeymap,
|
|
||||||
} from "@codemirror/lsp-client";
|
|
||||||
import { jumpToDefinitionKeymap } from "./lsp/definition";
|
|
||||||
|
|
||||||
const fixedHeightEditor = EditorView.theme({
|
const fixedHeightEditor = EditorView.theme({
|
||||||
"&": {
|
"&": {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: "1em",
|
minHeight: "0px",
|
||||||
resize: "horizontal",
|
resize: "horizontal",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
width: "768px",
|
width: "600px",
|
||||||
minWidth: "8em",
|
minWidth: "8em",
|
||||||
flex: "none",
|
|
||||||
fontSize: "16px",
|
|
||||||
scrollMargin: "100px",
|
|
||||||
},
|
},
|
||||||
".cm-scroller": { overflow: "auto scroll" },
|
".cm-scroller": { overflow: "auto" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const FileStatusEffect = StateEffect.define<string | null>();
|
export class EditorColumn {
|
||||||
const FileStatusField = StateField.define<string | null>({
|
|
||||||
create: () => null,
|
|
||||||
update(value, tr): string | null {
|
|
||||||
for (const effect of tr.effects) {
|
|
||||||
if (effect.is(FileStatusEffect)) {
|
|
||||||
value = effect.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
provide: (f) =>
|
|
||||||
showPanel.from(f, (state) => (state ? fileStatusPanel : null)),
|
|
||||||
});
|
|
||||||
|
|
||||||
function fileStatusPanel(view: EditorView) {
|
|
||||||
const dom = document.createElement("div");
|
|
||||||
dom.textContent = view.state.field(FileStatusField);
|
|
||||||
dom.style =
|
|
||||||
"padding: 2px 10px; font-size: 12px; color: white; background: #900;";
|
|
||||||
return { top: true, dom };
|
|
||||||
}
|
|
||||||
export class Editor extends Displayable {
|
|
||||||
view: EditorView;
|
view: EditorView;
|
||||||
file: OpenFile;
|
wrapper: HTMLElement;
|
||||||
|
|
||||||
private wordWrapCompartment = new Compartment();
|
constructor() {
|
||||||
private languageCompartment = new Compartment();
|
|
||||||
private lspCompartment = new Compartment();
|
|
||||||
|
|
||||||
dispatch(tr: Transaction, inhibitSync = false) {
|
|
||||||
this.view.update([tr]);
|
|
||||||
if (!inhibitSync) {
|
|
||||||
this.file.dispatch({ changes: tr.changes }, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(file: OpenFile) {
|
|
||||||
super();
|
|
||||||
this.file = file;
|
|
||||||
const kmap = keymap.of([
|
|
||||||
...defaultKeymap,
|
|
||||||
...searchKeymap,
|
|
||||||
...foldKeymap,
|
|
||||||
|
|
||||||
...lintKeymap,
|
|
||||||
...jumpToDefinitionKeymap,
|
|
||||||
...findReferencesKeymap,
|
|
||||||
...formatKeymap,
|
|
||||||
...renameKeymap,
|
|
||||||
indentWithTab,
|
|
||||||
{ key: "Mod-z", run: () => undo(file.target) },
|
|
||||||
{ key: "Mod-shift-z", run: () => redo(file.target) },
|
|
||||||
{
|
|
||||||
key: "Ctrl-s",
|
|
||||||
run: () => {
|
|
||||||
file.saveFile();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Alt-z",
|
|
||||||
run: () => {
|
|
||||||
this.toggleExt(
|
|
||||||
this.wordWrapCompartment,
|
|
||||||
EditorView.lineWrapping,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
this.view = new EditorView({
|
this.view = new EditorView({
|
||||||
doc: file.rootState.val.doc,
|
doc: "Start document",
|
||||||
dispatch: (trs) => this.dispatch(trs),
|
extensions: [basicSetup, oneDark, fixedHeightEditor],
|
||||||
extensions: [
|
|
||||||
oneDark,
|
|
||||||
fixedHeightEditor,
|
|
||||||
kmap,
|
|
||||||
FileStatusField,
|
|
||||||
|
|
||||||
this.wordWrapCompartment.of(EditorView.lineWrapping),
|
|
||||||
this.languageCompartment.of([]),
|
|
||||||
this.lspCompartment.of([]),
|
|
||||||
lineNumbers(),
|
|
||||||
highlightSpecialChars(),
|
|
||||||
foldGutter(),
|
|
||||||
lintGutter(),
|
|
||||||
drawSelection(),
|
|
||||||
dropCursor(),
|
|
||||||
EditorState.allowMultipleSelections.of(true),
|
|
||||||
indentOnInput(),
|
|
||||||
bracketMatching(),
|
|
||||||
closeBrackets(),
|
|
||||||
autocompletion(),
|
|
||||||
rectangularSelection(),
|
|
||||||
crosshairCursor(),
|
|
||||||
highlightActiveLine(),
|
|
||||||
highlightActiveLineGutter(),
|
|
||||||
highlightSelectionMatches(),
|
|
||||||
indentUnit.of(" "),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
van.derive(() => {
|
|
||||||
LanguageDescription.matchFilename(languages, file.filePath.val)
|
|
||||||
?.load()
|
|
||||||
.then((Lang) => {
|
|
||||||
const eff = this.languageCompartment.reconfigure(Lang);
|
|
||||||
this.view.dispatch({ effects: [eff] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load LSP extension for this file path if possible. This is optional
|
|
||||||
// and fails silently if the lsp client or server is not available.
|
|
||||||
van.derive(() => {
|
|
||||||
const p = file.filePath.val;
|
|
||||||
// Kick off async creation, then reconfigure compartment when ready
|
|
||||||
createLspExtension(p).then((ext: Extension) => {
|
|
||||||
try {
|
|
||||||
const eff = this.lspCompartment.reconfigure(ext);
|
|
||||||
this.view.dispatch({ effects: [eff] });
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to apply LSP extension:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
van.derive(() => {
|
|
||||||
const effects = FileStatusEffect.of(
|
|
||||||
file.diskDiscrepancyMessage.val,
|
|
||||||
);
|
|
||||||
const tr = this.view.state.update({ effects });
|
|
||||||
this.dispatch(tr, true);
|
|
||||||
});
|
});
|
||||||
|
console.log("Character width: ", this.view.defaultCharacterWidth);
|
||||||
|
this.wrapper = v.div({ class: "editorWrapper" }, this.view.dom);
|
||||||
}
|
}
|
||||||
|
|
||||||
get dom() {
|
get dom() {
|
||||||
return this.view.dom;
|
return this.wrapper;
|
||||||
}
|
|
||||||
|
|
||||||
focus() {
|
|
||||||
this.view.dom.scrollIntoView({ behavior: "smooth" });
|
|
||||||
this.view.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
title(): string {
|
|
||||||
return this.file.filePath.val + (this.file.isDirty() ? "*" : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (this.deleteFn) {
|
|
||||||
this.file.removeEditor(this, this.deleteFn);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleExt(compartment: Compartment, extension: Extension) {
|
|
||||||
const on = compartment.get(this.view.state) == extension;
|
|
||||||
this.view.dispatch({
|
|
||||||
effects: compartment.reconfigure(on ? [] : extension),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
import van, { State } from "vanjs-core";
|
|
||||||
import * as vanX from "vanjs-ext";
|
|
||||||
const v = van.tags;
|
|
||||||
|
|
||||||
import { OpenFile } from "./filestate";
|
|
||||||
import * as u from "./utils";
|
|
||||||
import { Editor } from "./editor";
|
|
||||||
import { Terminal } from "./terminal";
|
|
||||||
import { Displayable } from "./displayable";
|
|
||||||
|
|
||||||
const EditorWrapper = (
|
|
||||||
editor: State<Displayable>,
|
|
||||||
del: () => void,
|
|
||||||
k: number,
|
|
||||||
) => {
|
|
||||||
// Set the delete function on the editor when it's created
|
|
||||||
van.derive(() => {
|
|
||||||
if (!editor || !editor.val) return;
|
|
||||||
|
|
||||||
const findLeft = () => {
|
|
||||||
const list = editors[currentTab.val] || [];
|
|
||||||
for (let i = k - 1; i >= 0; i--) {
|
|
||||||
const c = list[i];
|
|
||||||
if (c) {
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const findRight = () => {
|
|
||||||
const list = editors[currentTab.val] || [];
|
|
||||||
for (let i = k + 1; i < list.length; i++) {
|
|
||||||
const c = list[i];
|
|
||||||
if (c) {
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrappedDelete = () => {
|
|
||||||
// Find nearest non-empty neighbor (scan left then right)
|
|
||||||
let neighborState: Displayable | null = findLeft();
|
|
||||||
if (!neighborState) {
|
|
||||||
neighborState = findRight();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the original delete function which updates the reactive list / DOM
|
|
||||||
del();
|
|
||||||
|
|
||||||
// After reactive update, focus the neighbor if available
|
|
||||||
if (neighborState) {
|
|
||||||
neighborState.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.val.addShortcut("Alt-[", () => findLeft()?.focus());
|
|
||||||
editor.val.addShortcut("Alt-]", () => findRight()?.focus());
|
|
||||||
editor.val.setDeleteFunction(wrappedDelete);
|
|
||||||
});
|
|
||||||
|
|
||||||
return v.div(
|
|
||||||
{ class: "flex flex-col group" },
|
|
||||||
v.div(
|
|
||||||
{
|
|
||||||
class: "flex group-focus-within:bg-blue-300 dark:group-focus-within:bg-blue-900",
|
|
||||||
},
|
|
||||||
v.span({ class: "mx-1 flex-1" }, () => editor.val.title()),
|
|
||||||
u.InlineButton(() => editor.val.close(), "Close", "❌"),
|
|
||||||
),
|
|
||||||
v.div({ class: "flex-auto h-4" }, editor.val.dom),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// give type to editors
|
|
||||||
const editors: Displayable[][] = vanX.reactive([[]]);
|
|
||||||
const currentTab = van.state(0);
|
|
||||||
|
|
||||||
export function addEditor(file: OpenFile): Editor {
|
|
||||||
const editor = file.createEditor();
|
|
||||||
editors[currentTab.val].push(vanX.noreactive(editor));
|
|
||||||
editor.focus();
|
|
||||||
return editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addTab() {
|
|
||||||
editors.push([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addTerminal() {
|
|
||||||
const term = new Terminal();
|
|
||||||
editors[currentTab.val].push(vanX.noreactive(term));
|
|
||||||
term.focus();
|
|
||||||
setTimeout(() => {
|
|
||||||
term.focus();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabHeader = (tab: State<Editor[]>, del: () => void, k: number) =>
|
|
||||||
v.div(
|
|
||||||
{
|
|
||||||
class: () =>
|
|
||||||
`flex-auto flex ${currentTab.val === k ? "bg-green-500 dark:bg-green-700" : ""}`,
|
|
||||||
onclick: () => (currentTab.val = k),
|
|
||||||
},
|
|
||||||
v.span({ class: "mx-1 flex-1" }, "Tab " + k),
|
|
||||||
u.InlineButton(del, "Close", "❌"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const EditorGrid = (tab: State<Editor[]>, del: () => void, k: number) => {
|
|
||||||
const main = v.main({
|
|
||||||
class: "flex flex-auto gap-4 overflow-x-auto min-width-4",
|
|
||||||
hidden: () => k !== currentTab.val,
|
|
||||||
});
|
|
||||||
vanX.list(main, tab.val, EditorWrapper);
|
|
||||||
return main;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TabBar = v.div({ class: "flex-none flex" });
|
|
||||||
|
|
||||||
export const EditorTabs = v.div(
|
|
||||||
{
|
|
||||||
class: "flex flex-col flex-auto min-w-4",
|
|
||||||
},
|
|
||||||
TabBar,
|
|
||||||
);
|
|
||||||
|
|
||||||
vanX.list(TabBar, editors, TabHeader);
|
|
||||||
vanX.list(EditorTabs, editors, EditorGrid);
|
|
||||||
|
|
||||||
function shortcutHandler(e: KeyboardEvent) {
|
|
||||||
if (e.key === "t" && e.altKey) {
|
|
||||||
if (e.type === "keydown") {
|
|
||||||
addTerminal();
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keyup", shortcutHandler, { capture: true });
|
|
||||||
document.addEventListener("keydown", shortcutHandler, { capture: true });
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
import {
|
|
||||||
EditorState,
|
|
||||||
EditorStateConfig,
|
|
||||||
TransactionSpec,
|
|
||||||
StateEffect,
|
|
||||||
Text,
|
|
||||||
Transaction,
|
|
||||||
ChangeSet,
|
|
||||||
} from "@codemirror/state";
|
|
||||||
import { history } from "@codemirror/commands";
|
|
||||||
import { Diagnostic, setDiagnostics } from "@codemirror/lint";
|
|
||||||
import { Editor } from "./editor";
|
|
||||||
import van, { State } from "vanjs-core";
|
|
||||||
import { WorkspaceFile } from "@codemirror/lsp-client";
|
|
||||||
import { inferLanguageFromPath } from "./lsp";
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
|
|
||||||
// export const openFiles: { [path: string]: OpenFile } = {};
|
|
||||||
export const openFiles: Map<string, OpenFile> = new Map();
|
|
||||||
|
|
||||||
export class OpenFile implements WorkspaceFile {
|
|
||||||
// Helper: find an open file instance by path
|
|
||||||
static findOpenFile(path?: string): OpenFile | undefined {
|
|
||||||
if (!path) return undefined;
|
|
||||||
return openFiles.get(path);
|
|
||||||
}
|
|
||||||
filePath: State<string>;
|
|
||||||
editors: Editor[];
|
|
||||||
rootState: State<EditorState>;
|
|
||||||
lastSaved: State<Text>;
|
|
||||||
expectedDiskContent: State<string | null>;
|
|
||||||
knownDiskContent: State<string | null>;
|
|
||||||
diskDiscrepancyMessage: State<string | null>;
|
|
||||||
|
|
||||||
constructor(cfg: EditorStateConfig) {
|
|
||||||
this.filePath = van.state(null);
|
|
||||||
this.editors = [];
|
|
||||||
this.rootState = van.state(
|
|
||||||
EditorState.create(cfg).update({
|
|
||||||
effects: [StateEffect.appendConfig.of([history()])],
|
|
||||||
}).state,
|
|
||||||
);
|
|
||||||
this.lastSaved = van.state(this.rootState.val.doc);
|
|
||||||
this.expectedDiskContent = van.state(null);
|
|
||||||
this.knownDiskContent = van.state(null);
|
|
||||||
|
|
||||||
// LSP version counter: starts at 1 when document is first created/opened
|
|
||||||
this.version = 1;
|
|
||||||
|
|
||||||
this.diskDiscrepancyMessage = van.derive(() => {
|
|
||||||
const expected = this.expectedDiskContent.val;
|
|
||||||
const known = this.knownDiskContent.val;
|
|
||||||
if (known === null) {
|
|
||||||
return "File has been removed from disk.";
|
|
||||||
} else if (expected === null) {
|
|
||||||
return "File has been created on disk.";
|
|
||||||
} else if (expected !== known) {
|
|
||||||
return "File has been changed on disk.";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async openFile(filePath?: string): Promise<OpenFile> {
|
|
||||||
if (filePath && openFiles.has(filePath)) {
|
|
||||||
return openFiles.get(filePath)!;
|
|
||||||
}
|
|
||||||
const { content, path } = await window.electronAPI.readFile(filePath);
|
|
||||||
const file = new OpenFile({ doc: content });
|
|
||||||
file.expectedDiskContent.val = content;
|
|
||||||
file.knownDiskContent.val = content;
|
|
||||||
file.setPath(path);
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setPath(path: string) {
|
|
||||||
if (this.filePath.val) {
|
|
||||||
openFiles.delete(this.filePath.val);
|
|
||||||
}
|
|
||||||
this.filePath.val = path;
|
|
||||||
openFiles.set(path, this);
|
|
||||||
// TODO: what if openFiles[path] already exists?
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveFile() {
|
|
||||||
if (this.filePath.val) {
|
|
||||||
const doc = this.rootState.val.doc.toString();
|
|
||||||
await window.electronAPI.saveFile(doc, this.filePath.val);
|
|
||||||
this.lastSaved.val = this.rootState.val.doc;
|
|
||||||
this.expectedDiskContent.val = doc;
|
|
||||||
} else {
|
|
||||||
await this.saveAs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveAs(filePath?: string) {
|
|
||||||
const doc = this.rootState.val.doc.toString();
|
|
||||||
const { path } = await window.electronAPI.saveFile(doc, filePath);
|
|
||||||
this.setPath(path);
|
|
||||||
this.lastSaved.val = this.rootState.val.doc;
|
|
||||||
this.expectedDiskContent.val = doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to create and return a new EditorView for this file
|
|
||||||
createEditor(): Editor {
|
|
||||||
const editor = new Editor(this);
|
|
||||||
this.editors.push(editor);
|
|
||||||
editor.dispatch(
|
|
||||||
editor.view.state.update(
|
|
||||||
setDiagnostics(editor.view.state, this.diagnostics || []),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to remove an editor and clean up if no more editors exist
|
|
||||||
async removeEditor(editor: Editor, callback: () => void) {
|
|
||||||
const index = this.editors.indexOf(editor);
|
|
||||||
if (index == -1) return;
|
|
||||||
|
|
||||||
// If this is the last editor and the file is dirty, confirm before closing
|
|
||||||
if (this.editors.length === 1 && this.isDirty()) {
|
|
||||||
const confirmed = await this.confirmClose();
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the editor from the list
|
|
||||||
this.editors.splice(index, 1);
|
|
||||||
editor.view.destroy();
|
|
||||||
|
|
||||||
// If no more editors, remove from openFiles dictionary
|
|
||||||
if (this.editors.length === 0) {
|
|
||||||
openFiles.delete(this.filePath.val);
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to confirm closing of dirty file
|
|
||||||
private async confirmClose(): Promise<boolean> {
|
|
||||||
const fileName = this.filePath.val
|
|
||||||
? this.filePath.val.split("/").pop()
|
|
||||||
: "untitled";
|
|
||||||
// TODO: change message based on whether file exists on disk
|
|
||||||
// e.g. if it was removed or changed
|
|
||||||
const message = `Do you want to save the changes to ${fileName}?`;
|
|
||||||
const result = await window.electronAPI.showConfirmDialog(
|
|
||||||
message,
|
|
||||||
"Save Changes?",
|
|
||||||
["Save", "Don't Save", "Cancel"],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === "Save") {
|
|
||||||
await this.saveFile();
|
|
||||||
return true;
|
|
||||||
} else if (result === "Don't Save") {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(trs: TransactionSpec, origin?: Editor) {
|
|
||||||
const transaction = this.rootState.val.update(trs);
|
|
||||||
this.rootState.val = transaction.state;
|
|
||||||
|
|
||||||
if (transaction.changes && !transaction.changes.empty) {
|
|
||||||
this.changeSet = this.changes.compose(transaction.changes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (origin) {
|
|
||||||
const es = this.editors.filter((e) => e !== origin);
|
|
||||||
es.forEach((e) => e.dispatch(e.view.state.update(trs), true));
|
|
||||||
} else {
|
|
||||||
this.editors.forEach((e) => {
|
|
||||||
const changes = transaction.changes;
|
|
||||||
const userEvent = transaction.annotation(Transaction.userEvent);
|
|
||||||
const annotations = userEvent
|
|
||||||
? [Transaction.userEvent.of(userEvent)]
|
|
||||||
: [];
|
|
||||||
e.dispatch(e.view.state.update({ changes, annotations }), true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get target() {
|
|
||||||
return {
|
|
||||||
state: this.rootState.val,
|
|
||||||
dispatch: (tr: TransactionSpec) => this.dispatch(tr),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
isDirty(): boolean {
|
|
||||||
return !this.lastSaved.val.eq(this.rootState.val.doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
// LSP stuff
|
|
||||||
version: number;
|
|
||||||
get uri(): string | null {
|
|
||||||
if (!this.filePath.val) return null;
|
|
||||||
return `file://${this.filePath.val}`;
|
|
||||||
}
|
|
||||||
get languageId(): string {
|
|
||||||
return inferLanguageFromPath(this.filePath.val || "") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
doc: Text;
|
|
||||||
private changeSet: ChangeSet;
|
|
||||||
get changes(): ChangeSet {
|
|
||||||
if (!this.changeSet) {
|
|
||||||
this.clearChanges();
|
|
||||||
}
|
|
||||||
return this.changeSet;
|
|
||||||
}
|
|
||||||
clearChanges(): void {
|
|
||||||
this.changeSet = ChangeSet.empty(this.rootState.val.doc.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return an EditorView to be used by the LSP Workspace for position mapping.
|
|
||||||
// If `main` is provided and belongs to this open file, return it. Otherwise
|
|
||||||
// return the first available editor view, or null if none exist.
|
|
||||||
getView(main?: EditorView): EditorView | null {
|
|
||||||
if (main) {
|
|
||||||
const found = this.editors.find((e) => e.view === main);
|
|
||||||
if (found) return main;
|
|
||||||
}
|
|
||||||
if (this.editors.length > 0) return this.editors[0].view;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private diagnostics: Diagnostic[];
|
|
||||||
setDiagnostics(diagnostics: Diagnostic[]) {
|
|
||||||
this.diagnostics = diagnostics;
|
|
||||||
for (const editor of this.editors) {
|
|
||||||
editor.view.dispatch(
|
|
||||||
setDiagnostics(editor.view.state, diagnostics),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +1,43 @@
|
||||||
import van from "vanjs-core";
|
import van from "vanjs-core";
|
||||||
const v = van.tags;
|
const v = van.tags;
|
||||||
import type { FolderTree } from "../types/global";
|
import type { FolderTree } from "../types/global";
|
||||||
import { addEditor } from "./editorgrid";
|
|
||||||
import { OpenFile } from "./filestate";
|
|
||||||
|
|
||||||
import * as u from "./utils";
|
|
||||||
|
|
||||||
const folderTreeState = van.state<FolderTree | null>(null);
|
const folderTreeState = van.state<FolderTree | null>(null);
|
||||||
|
|
||||||
van.derive(() => {
|
|
||||||
if (folderTreeState.val) {
|
|
||||||
document.title = folderTreeState.val.path + " - Miller code editor";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openFolder() {
|
async function openFolder() {
|
||||||
const folderTree = await window.electronAPI.openFolder().catch(alert);
|
const folderTree = await window.electronAPI.openFolder().catch(alert);
|
||||||
if (!folderTree) return;
|
if (!folderTree) return;
|
||||||
folderTreeState.val = folderTree;
|
folderTreeState.val = folderTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the current folder tree from main (re-open)
|
const FolderTreeView = () => {
|
||||||
let refreshScheduled = false;
|
|
||||||
async function refreshFolder() {
|
|
||||||
refreshScheduled = false;
|
|
||||||
const folderTree = await window.electronAPI.getWorkspaceTree().catch(alert);
|
|
||||||
if (!folderTree) return;
|
|
||||||
folderTreeState.val = folderTree;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to filesystem events and refresh tree when directories change
|
|
||||||
window.electronAPI.onFsEvent(async (ev: { event: string; path: string }) => {
|
|
||||||
// If no workspace is loaded ignore
|
|
||||||
if (!folderTreeState.val) return;
|
|
||||||
const workspaceRoot = folderTreeState.val.path;
|
|
||||||
if (!ev.path.startsWith(workspaceRoot)) return;
|
|
||||||
|
|
||||||
// For directory-level changes or create/unlink/rename, refresh the tree
|
|
||||||
if (
|
|
||||||
ev.event === "addDir" ||
|
|
||||||
ev.event === "unlinkDir" ||
|
|
||||||
ev.event === "add" ||
|
|
||||||
ev.event === "unlink"
|
|
||||||
) {
|
|
||||||
// Debounce-ish: schedule a refresh
|
|
||||||
if (!refreshScheduled) {
|
|
||||||
refreshScheduled = true;
|
|
||||||
setTimeout(() => refreshFolder(), 50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a file changed on disk and it's open, show disk version panels
|
|
||||||
if (ev.event === "change" || ev.event === "add" || ev.event === "unlink") {
|
|
||||||
const openFile = OpenFile.findOpenFile(ev.path);
|
|
||||||
if (!openFile) return;
|
|
||||||
// Read latest contents from disk
|
|
||||||
const data = await window.electronAPI
|
|
||||||
.readFile(ev.path)
|
|
||||||
.catch(() => null);
|
|
||||||
if (!data) return;
|
|
||||||
if (ev.event === "unlink") {
|
|
||||||
openFile.knownDiskContent.val = null;
|
|
||||||
} else {
|
|
||||||
openFile.knownDiskContent.val = data.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const FolderTreeView = () => {
|
|
||||||
if (!folderTreeState.val) {
|
if (!folderTreeState.val) {
|
||||||
return v.div(
|
return v.div(
|
||||||
{ class: "text-center m-4" },
|
{ style: "text-align: center; margin-top: 25px;" },
|
||||||
v.p("No folder selected!"),
|
v.p("No folder selected!"),
|
||||||
u.Button(openFolder, "Open Folder"),
|
v.button({ onclick: openFolder }, "Open Folder"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return v.div(
|
return v.div(
|
||||||
{ class: "mx-1" },
|
v.span(
|
||||||
v.div(
|
{ style: "font-weight: bold; margin-right: 1em;" },
|
||||||
{ class: "flex w-full" },
|
folderTreeState.val?.name ?? "No folder",
|
||||||
v.span(
|
),
|
||||||
{ class: "font-bold flex-1" },
|
v.button(
|
||||||
folderTreeState.val?.name ?? "No folder",
|
{
|
||||||
),
|
onclick: openFolder,
|
||||||
u.InlineButton(refreshFolder, "Refresh current folder", "⟳"),
|
title: "Refresh current folder",
|
||||||
u.InlineButton(openFolder, "Open another folder", "📁"),
|
style: "margin-right: 0.5em;",
|
||||||
|
},
|
||||||
|
"⟳",
|
||||||
|
),
|
||||||
|
v.button(
|
||||||
|
{
|
||||||
|
onclick: openFolder,
|
||||||
|
title: "Open another folder",
|
||||||
|
style: "margin-right: 0.5em;",
|
||||||
|
},
|
||||||
|
"📁",
|
||||||
),
|
),
|
||||||
folderTreeState.val.children?.map(FsItemView) || [],
|
folderTreeState.val.children?.map(FsItemView) || [],
|
||||||
);
|
);
|
||||||
|
|
@ -93,35 +46,19 @@ export const FolderTreeView = () => {
|
||||||
// TODO: determine if lazy DOM creation is better or not.
|
// TODO: determine if lazy DOM creation is better or not.
|
||||||
// Alternatively, investigate lazy FS traversal in main.
|
// Alternatively, investigate lazy FS traversal in main.
|
||||||
const FsItemView = (tree: FolderTree): HTMLElement => {
|
const FsItemView = (tree: FolderTree): HTMLElement => {
|
||||||
if (tree.type === "file")
|
if (tree.type === "file") return v.p(tree.name);
|
||||||
return v.p(
|
|
||||||
{
|
|
||||||
class: "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700",
|
|
||||||
onclick: async () =>
|
|
||||||
addEditor(await OpenFile.openFile(tree.path)),
|
|
||||||
},
|
|
||||||
v.span("📄"),
|
|
||||||
tree.name,
|
|
||||||
);
|
|
||||||
const isOpen = van.state(false);
|
const isOpen = van.state(false);
|
||||||
const children = () =>
|
const children = () =>
|
||||||
isOpen.val
|
isOpen.val
|
||||||
? v.ul({ class: "pl-4" }, tree.children?.map(FsItemView))
|
? v.div(tree.children?.map(FsItemView))
|
||||||
: v.div({ ariaBusy: true });
|
: v.div({ ariaBusy: true });
|
||||||
const folder = v.details(
|
const folder = v.details(
|
||||||
{
|
{ ontoggle: () => (isOpen.val = folder.open) },
|
||||||
class: "flex-auto inline",
|
v.summary(tree.name),
|
||||||
ontoggle: () => (isOpen.val = folder.open),
|
|
||||||
},
|
|
||||||
v.summary(
|
|
||||||
{
|
|
||||||
class: "cursor-pointer flex hover:bg-gray-100 dark:hover:bg-gray-700",
|
|
||||||
},
|
|
||||||
v.span(() => (isOpen.val ? "📂" : "📁")),
|
|
||||||
tree.name,
|
|
||||||
),
|
|
||||||
children,
|
children,
|
||||||
);
|
);
|
||||||
|
|
||||||
return folder;
|
return folder;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const NavBar = FolderTreeView;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,66 @@
|
||||||
@import "tailwindcss";
|
/* Grid layout stuff (main) */
|
||||||
|
body {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
grid-template-columns: auto minmax(0, 4fr);
|
||||||
|
}
|
||||||
|
|
||||||
details > summary {
|
body > header {
|
||||||
list-style: none;
|
padding: 0px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
details > summary::-webkit-details-marker {
|
|
||||||
display: none;
|
body > main {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
resize: horizontal;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid green;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
min-width: 90px;
|
||||||
|
max-width: 50vw;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor grid stuff */
|
||||||
|
#editorGrid {
|
||||||
|
display: grid;
|
||||||
|
height: minmax(0, 100%);
|
||||||
|
width: minmax(0, 100%);
|
||||||
|
overflow: auto;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: min-content;
|
||||||
|
grid-gap: 1em;
|
||||||
|
grid-template-columns: none;
|
||||||
|
/* grid-template-columns: 1fr; */
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorWrapper {
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar styling */
|
||||||
|
nav details {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
nav details[open] summary,
|
||||||
|
nav details summary,
|
||||||
|
nav div p {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
nav details[open] :not(summary) {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
/* closed detail summary */
|
||||||
|
|
|
||||||
329
src/app/lsp.ts
329
src/app/lsp.ts
|
|
@ -1,329 +0,0 @@
|
||||||
import * as lsp from "vscode-languageserver-protocol";
|
|
||||||
import { Extension, TransactionSpec } from "@codemirror/state";
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
|
|
||||||
import {
|
|
||||||
LSPClient,
|
|
||||||
LSPPlugin,
|
|
||||||
Workspace,
|
|
||||||
hoverTooltips,
|
|
||||||
signatureHelp,
|
|
||||||
WorkspaceMapping
|
|
||||||
} from "@codemirror/lsp-client";
|
|
||||||
|
|
||||||
import { serverCompletion } from "./lsp/completion";
|
|
||||||
import { OpenFile } from "./filestate";
|
|
||||||
import { serverDiagnostics } from "./lsp/diagnostics";
|
|
||||||
|
|
||||||
// Create a very small MessagePort-based transport implementation
|
|
||||||
// compatible with @codemirror/lsp-client's expected Transport interface.
|
|
||||||
async function simpleMessagePortTransport(port: MessagePort) {
|
|
||||||
let handlers: ((value: string) => void)[] = [];
|
|
||||||
const onMessage = (e: MessageEvent) => {
|
|
||||||
const d = e.data;
|
|
||||||
if (typeof d === "string") {
|
|
||||||
for (const h of handlers) h(d);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
port.addEventListener("message", onMessage);
|
|
||||||
// The port must be started to begin receiving messages
|
|
||||||
port.start();
|
|
||||||
|
|
||||||
return {
|
|
||||||
send(message: string) {
|
|
||||||
try {
|
|
||||||
port.postMessage(message);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to post message on MessagePort", err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subscribe(handler: (value: string) => void) {
|
|
||||||
handlers.push(handler);
|
|
||||||
},
|
|
||||||
unsubscribe(handler: (value: string) => void) {
|
|
||||||
handlers = handlers.filter((h) => h !== handler);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Given a local file path like "/home/user/proj/src/foo.ts" produce a
|
|
||||||
// file:// URI (required by many LSP setups).
|
|
||||||
function filePathToUri(path: string) {
|
|
||||||
// Prefer URL constructor to ensure proper escaping
|
|
||||||
try {
|
|
||||||
const u = new URL("file://" + path);
|
|
||||||
return u.toString();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to convert file path to URI via URL:", err);
|
|
||||||
return "file://" + path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map of active clients keyed by `${language}::${rootUri}`
|
|
||||||
const clients = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
client: LSPClient;
|
|
||||||
transport?: any;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
export function inferLanguageFromPath(
|
|
||||||
filePath: string | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
if (!filePath) return undefined;
|
|
||||||
const m = filePath.match(/\.([^.\/]+)$/);
|
|
||||||
if (!m) return undefined;
|
|
||||||
const ext = m[1].toLowerCase();
|
|
||||||
if (ext === "ts" || ext === "tsx" || ext === "js" || ext === "jsx")
|
|
||||||
return "typescript";
|
|
||||||
if (ext === "py") return "python";
|
|
||||||
if (ext === "c" || ext === "h" || ext === "cpp" || ext === "hpp")
|
|
||||||
return "cpp";
|
|
||||||
if (ext === "java") return "java";
|
|
||||||
if (ext === "go") return "go";
|
|
||||||
if (ext === "rs") return "rust";
|
|
||||||
if (ext === "php") return "php";
|
|
||||||
if (ext === "rb") return "ruby";
|
|
||||||
if (ext === "cs") return "csharp";
|
|
||||||
if (ext === "html" || ext === "htm") return "html";
|
|
||||||
if (ext === "css" || ext === "scss" || ext === "less") return "css";
|
|
||||||
if (ext === "json") return "json";
|
|
||||||
if (ext === "xml") return "xml";
|
|
||||||
if (ext === "yaml" || ext === "yml") return "yaml";
|
|
||||||
if (ext === "md") return "markdown";
|
|
||||||
if (ext === "lua") return "lua";
|
|
||||||
if (ext === "sh" || ext === "bash") return "shellscript";
|
|
||||||
if (ext === "hs") return "haskell";
|
|
||||||
// add more mappings as needed
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workspace implementation that maps our OpenFile model to the LSP client's
|
|
||||||
// expectations. This supports multiple views per OpenFile by using the
|
|
||||||
// OpenFile.getView method.
|
|
||||||
class OpenFileWorkspace extends Workspace {
|
|
||||||
files: OpenFile[] = [];
|
|
||||||
private fileVersions: { [uri: string]: number } = Object.create(null);
|
|
||||||
|
|
||||||
nextFileVersion(uri: string) {
|
|
||||||
return (this.fileVersions[uri] = (this.fileVersions[uri] ?? -1) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(client: LSPClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look through known workspace files and update their docs/versions
|
|
||||||
// based on the editor views or the OpenFile state when no view exists.
|
|
||||||
syncFiles() {
|
|
||||||
const result = [];
|
|
||||||
for (const file of this.files) {
|
|
||||||
const prevDoc = file.doc;
|
|
||||||
const changes = file.changes;
|
|
||||||
if (changes && !changes.empty) {
|
|
||||||
result.push({ file, prevDoc, changes });
|
|
||||||
file.doc = file.rootState.val.doc;
|
|
||||||
file.version = this.nextFileVersion(file.uri);
|
|
||||||
file.clearChanges();
|
|
||||||
}
|
|
||||||
for (const e of file.editors) {
|
|
||||||
const plugin = LSPPlugin.get(e.view);
|
|
||||||
if (plugin) {
|
|
||||||
plugin.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
openFile(uri: string, _languageId: string, view: EditorView) {
|
|
||||||
console.log("LSP: attempting to open file", uri);
|
|
||||||
if (this.getFile(uri)) return;
|
|
||||||
// Try to map to an existing OpenFile instance, prefer using its doc
|
|
||||||
const path = uri.replace(/^file:\/\//, "");
|
|
||||||
const of = OpenFile.findOpenFile(path);
|
|
||||||
if (!of) {
|
|
||||||
console.warn("LSP: attempted to open unknown file", uri);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
of.doc = view.state.doc;
|
|
||||||
this.files.push(of);
|
|
||||||
this.client.didOpen(of);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFile(uri: string, update: TransactionSpec): void {
|
|
||||||
const file = this.getFile(uri) as OpenFile;
|
|
||||||
if (!file) {
|
|
||||||
console.warn("LSP: attempted to update unknown file", uri);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// TODO: maybe couple undos across editors for things like LSP rename?
|
|
||||||
file.dispatch(update);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeFile(uri: string, _view: EditorView) {
|
|
||||||
const path = uri.replace(/^file:\/\//, "");
|
|
||||||
const of = OpenFile.findOpenFile(path);
|
|
||||||
// If OpenFile exists and still has editors, defer closing
|
|
||||||
console.log("LSP: attempting to close file", uri, of);
|
|
||||||
if (of && of.editors.length > 0) return;
|
|
||||||
this.files = this.files.filter((f) => f.uri !== uri);
|
|
||||||
console.log("LSP: closing file", uri);
|
|
||||||
this.client.didClose(uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyTextEdits(mapping: WorkspaceMapping, workspace: Workspace, uri: string, edits: lsp.TextEdit[], userEvent: string) {
|
|
||||||
const file = workspace.getFile(uri);
|
|
||||||
if(!file) return;
|
|
||||||
workspace.updateFile(uri, {
|
|
||||||
changes: edits.map(change => ({
|
|
||||||
from: mapping.mapPosition(uri, change.range.start),
|
|
||||||
to: mapping.mapPosition(uri, change.range.end),
|
|
||||||
insert: change.newText,
|
|
||||||
})),
|
|
||||||
userEvent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyWorkspaceEdit(mapping: WorkspaceMapping, workspace: Workspace, edit: lsp.WorkspaceEdit, userEvent: string) {
|
|
||||||
for (const uri in edit.changes) {
|
|
||||||
const lspChanges = edit.changes[uri];
|
|
||||||
if (!lspChanges.length) continue;
|
|
||||||
applyTextEdits(mapping, workspace, uri, lspChanges, userEvent);
|
|
||||||
}
|
|
||||||
for (const change of edit.documentChanges) {
|
|
||||||
if (Object.hasOwn(change, "kind")) {
|
|
||||||
const action = change as (lsp.CreateFile | lsp.RenameFile | lsp.DeleteFile);
|
|
||||||
console.warn("Unsupported edit type!", action.kind);
|
|
||||||
}
|
|
||||||
const lspChanges = change as lsp.TextDocumentEdit;
|
|
||||||
applyTextEdits(mapping, workspace, lspChanges.textDocument.uri, lspChanges.edits, userEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public helper: attempt to create an LSP extension for `filePath`.
|
|
||||||
// Returns an empty array (no-op extension) on failure so callers can safely
|
|
||||||
// reconfigure their compartments with the returned value.
|
|
||||||
export async function createLspExtension(
|
|
||||||
filePath?: string,
|
|
||||||
): Promise<Extension> {
|
|
||||||
if (!filePath) return [];
|
|
||||||
// Determine workspace root (filesystem path) and a file:// URI for LSP
|
|
||||||
let rootPath: string | undefined = undefined;
|
|
||||||
let rootUri: string | undefined = undefined;
|
|
||||||
try {
|
|
||||||
const ws = await window.electronAPI.getCurrentWorkspace();
|
|
||||||
if (ws && ws.root) {
|
|
||||||
rootPath = ws.root;
|
|
||||||
rootUri = filePathToUri(ws.root);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to get workspace root from main process:", e);
|
|
||||||
}
|
|
||||||
if (!rootPath) {
|
|
||||||
try {
|
|
||||||
const dir = filePath.replace(/\/[^\/]*$/, "");
|
|
||||||
rootPath = dir;
|
|
||||||
rootUri = filePathToUri(dir);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to derive workspace dir from file path:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const language = inferLanguageFromPath(filePath);
|
|
||||||
const serverKey = `${language || "auto"}::${rootPath || ""}`;
|
|
||||||
|
|
||||||
// Reuse existing client if available
|
|
||||||
if (clients.has(serverKey)) {
|
|
||||||
const entry = clients.get(serverKey)!;
|
|
||||||
await entry.client.initializing;
|
|
||||||
try {
|
|
||||||
const uri = filePathToUri(filePath);
|
|
||||||
const ext = entry.client.plugin(uri);
|
|
||||||
return ext;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
"Failed to create LSP plugin from existing client:",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise request a new server/port from main and create a client
|
|
||||||
try {
|
|
||||||
// Pass a filesystem root path to main so it can set cwd correctly.
|
|
||||||
await window.electronAPI.connectLsp({ language, root: rootPath });
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to request LSP server from main:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
let port: MessagePort;
|
|
||||||
try {
|
|
||||||
port = await new Promise<MessagePort>((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
window.removeEventListener("message", onMessage);
|
|
||||||
reject(new Error("Timed out waiting for LSP MessagePort"));
|
|
||||||
}, 5000);
|
|
||||||
function onMessage(e: MessageEvent) {
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
e.data &&
|
|
||||||
e.data.source === "electron-lsp" &&
|
|
||||||
e.data.serverKey === serverKey
|
|
||||||
) {
|
|
||||||
const ports = e.ports;
|
|
||||||
if (ports && ports.length > 0) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
window.removeEventListener("message", onMessage);
|
|
||||||
resolve(ports[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
window.removeEventListener("message", onMessage);
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("message", onMessage);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to receive LSP MessagePort:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
let transport;
|
|
||||||
try {
|
|
||||||
transport = await simpleMessagePortTransport(port);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to create transport from MessagePort:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = new LSPClient({
|
|
||||||
extensions: [
|
|
||||||
serverDiagnostics(),
|
|
||||||
serverCompletion(),
|
|
||||||
hoverTooltips(),
|
|
||||||
signatureHelp(),
|
|
||||||
],
|
|
||||||
rootUri: rootUri,
|
|
||||||
workspace: (c) => new OpenFileWorkspace(c),
|
|
||||||
});
|
|
||||||
client.connect(transport);
|
|
||||||
await client.initializing;
|
|
||||||
|
|
||||||
// Store client.
|
|
||||||
clients.set(serverKey, { client, transport });
|
|
||||||
|
|
||||||
const uri = filePathToUri(filePath);
|
|
||||||
return client.plugin(uri);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to create LSP client plugin:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import * as lsp from "vscode-languageserver-protocol";
|
|
||||||
import { StateEffect, StateField } from "@codemirror/state";
|
|
||||||
import { GutterMarker, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
|
||||||
import { LSPPlugin, LSPClientExtension } from "@codemirror/lsp-client";
|
|
||||||
|
|
||||||
type GutterCodeAction = {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeActionGutterMarker = new class extends GutterMarker {
|
|
||||||
toDOM() { return document.createTextNode("💡"); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeActionGutterEffect = StateEffect.define<lsp.CodeAction[]>();
|
|
||||||
|
|
||||||
const codeActionGutterState = StateField.define<lsp.CodeAction[]>({
|
|
||||||
create() { return []; },
|
|
||||||
update(updateCodeActions, tr) {
|
|
||||||
for (let e of tr.effects) {
|
|
||||||
if (e.is(codeActionGutterEffect)) {
|
|
||||||
return e.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updateCodeActions;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncCodeAction = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
pending: any | null = null;
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged) {
|
|
||||||
if (this.pending != null) clearTimeout(this.pending);
|
|
||||||
this.pending = setTimeout(() => {
|
|
||||||
this.pending = null;
|
|
||||||
const plugin = LSPPlugin.get(update.view);
|
|
||||||
if (plugin) {
|
|
||||||
plugin.client.sync();
|
|
||||||
updateCodeActions(plugin);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
destroy() {
|
|
||||||
if (this.pending != null) clearTimeout(this.pending);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function updateCodeActions(plugin: LSPPlugin) {
|
|
||||||
const hasCap = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities["codeActionProvider"] : null;
|
|
||||||
if (hasCap === false) return Promise.resolve(null);
|
|
||||||
const params: lsp.CodeActionParams = {
|
|
||||||
textDocument: { uri: plugin.uri },
|
|
||||||
range: {
|
|
||||||
start: plugin.toPosition(0),
|
|
||||||
end: plugin.toPosition(plugin.syncedDoc.length),
|
|
||||||
},
|
|
||||||
context: {
|
|
||||||
diagnostics: [], // TODO fix
|
|
||||||
triggerKind: lsp.CodeActionTriggerKind.Automatic,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plugin.client.request<
|
|
||||||
lsp.CodeActionParams,
|
|
||||||
(lsp.Command | lsp.CodeAction)[]
|
|
||||||
>(
|
|
||||||
"textDocument/codeAction",
|
|
||||||
params
|
|
||||||
).then((actions) => {
|
|
||||||
const file = plugin.client.workspace.getFile(plugin.uri) as OpenFile;
|
|
||||||
if (!file) return false;
|
|
||||||
if ()
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function codeAction(config: {}): LSPClientExtension {
|
|
||||||
return {
|
|
||||||
clientCapabilities: {
|
|
||||||
codeAction: {
|
|
||||||
dataSupport: true,
|
|
||||||
resolveSupport: ["edit"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
editorExtension: syncCodeAction,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
import type * as lsp from "vscode-languageserver-protocol"
|
|
||||||
import { EditorState, Extension, Facet } from "@codemirror/state"
|
|
||||||
import { CompletionSource, Completion, CompletionContext, snippet, autocompletion } from "@codemirror/autocomplete"
|
|
||||||
import { LSPPlugin, LSPClientExtension } from "@codemirror/lsp-client"
|
|
||||||
|
|
||||||
/// Register the [language server completion
|
|
||||||
/// source](#lsp-client.serverCompletionSource) as an autocompletion
|
|
||||||
/// source.
|
|
||||||
export function serverCompletion(config: {
|
|
||||||
/// By default, the completion source that asks the language server
|
|
||||||
/// for completions is added as a regular source, in addition to any
|
|
||||||
/// other sources. Set this to true to make it replace all
|
|
||||||
/// completion sources.
|
|
||||||
override?: boolean
|
|
||||||
/// Set a custom
|
|
||||||
/// [`validFor`](#autocomplete.CompletionResult.validFor) expression
|
|
||||||
/// to use in the completion results. By default, the library uses an
|
|
||||||
/// expression that accepts word characters, optionally prefixed by
|
|
||||||
/// any non-word prefixes found in the results.
|
|
||||||
validFor?: RegExp
|
|
||||||
} = {}): LSPClientExtension {
|
|
||||||
let result: Extension[]
|
|
||||||
if (config.override) {
|
|
||||||
result = [autocompletion({ override: [serverCompletionSource] })]
|
|
||||||
} else {
|
|
||||||
let data = [{ autocomplete: serverCompletionSource }]
|
|
||||||
result = [autocompletion(), EditorState.languageData.of(() => data)]
|
|
||||||
}
|
|
||||||
if (config.validFor) result.push(completionConfig.of({ validFor: config.validFor }))
|
|
||||||
return {
|
|
||||||
clientCapabilities: {
|
|
||||||
textDocument: {
|
|
||||||
completion: {
|
|
||||||
completionItem: {
|
|
||||||
snippetSupport: true,
|
|
||||||
// documentationFormat: ["markdown", "plaintext"],
|
|
||||||
resolveSupport: ["documentation"],
|
|
||||||
labelDetailsSupport: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
editorExtension: result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const completionConfig = Facet.define<{ validFor: RegExp }, { validFor: RegExp | null }>({
|
|
||||||
combine: results => results.length ? results[0] : { validFor: null }
|
|
||||||
})
|
|
||||||
|
|
||||||
function getCompletions(plugin: LSPPlugin, pos: number, context: lsp.CompletionContext, abort?: CompletionContext) {
|
|
||||||
const hasCapability = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities["completionProvider"] : null;
|
|
||||||
if (hasCapability === false) return Promise.resolve(null)
|
|
||||||
plugin.client.sync()
|
|
||||||
let params: lsp.CompletionParams = {
|
|
||||||
position: plugin.toPosition(pos),
|
|
||||||
textDocument: { uri: plugin.uri },
|
|
||||||
context
|
|
||||||
}
|
|
||||||
if (abort) abort.addEventListener("abort", () => plugin.client.cancelRequest(params))
|
|
||||||
return plugin.client.request<lsp.CompletionParams, lsp.CompletionItem[] | lsp.CompletionList | null>(
|
|
||||||
"textDocument/completion", params)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveDocs(plugin: LSPPlugin, item: lsp.CompletionItem, abort?: CompletionContext): Promise<lsp.CompletionItem> {
|
|
||||||
const hasCapability = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities["completionProvider"]["resolveProvider"] : null;
|
|
||||||
if (hasCapability === false) {
|
|
||||||
console.warn("No resolving support but also no docs given!");
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
if (abort) abort.addEventListener("abort", () => plugin.client.cancelRequest(item));
|
|
||||||
return await plugin.client.request("completionItem/resolve", item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for non-alphanumeric prefixes in the completions, and return a
|
|
||||||
// regexp that matches them, to use in validFor
|
|
||||||
function prefixRegexp(items: readonly lsp.CompletionItem[]) {
|
|
||||||
let step = Math.ceil(items.length / 50), prefixes: string[] = []
|
|
||||||
for (let i = 0; i < items.length; i += step) {
|
|
||||||
let item = items[i], text = item.textEdit?.newText || item.textEditText || item.insertText || item.label
|
|
||||||
if (!/^\w/.test(text)) {
|
|
||||||
let prefix = /^[^\w]*/.exec(text)![0]
|
|
||||||
if (prefixes.indexOf(prefix) < 0) prefixes.push(prefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!prefixes.length) return /^\w*$/
|
|
||||||
return new RegExp("^(?:" + prefixes.map((RegExp as any).escape || (s => s.replace(/[^\w\s]/g, "\\$&"))).join("|") + ")?\\w*$")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A completion source that requests completions from a language
|
|
||||||
/// server.
|
|
||||||
export const serverCompletionSource: CompletionSource = context => {
|
|
||||||
const plugin = context.view && LSPPlugin.get(context.view)
|
|
||||||
if (!plugin) return null
|
|
||||||
let triggerChar = ""
|
|
||||||
if (!context.explicit) {
|
|
||||||
triggerChar = context.view.state.sliceDoc(context.pos - 1, context.pos)
|
|
||||||
let triggers = plugin.client.serverCapabilities?.completionProvider?.triggerCharacters
|
|
||||||
if (!/[a-zA-Z_]/.test(triggerChar) && !(triggers && triggers.indexOf(triggerChar) > -1)) return null
|
|
||||||
}
|
|
||||||
return getCompletions(plugin, context.pos, {
|
|
||||||
triggerCharacter: triggerChar,
|
|
||||||
triggerKind: context.explicit ? 1 /* Invoked */ : 2 /* TriggerCharacter */
|
|
||||||
}, context).then(result => {
|
|
||||||
if (!result) return null
|
|
||||||
if (Array.isArray(result)) result = { items: result } as lsp.CompletionList
|
|
||||||
let { from, to } = completionResultRange(context, result)
|
|
||||||
let defaultCommitChars = result.itemDefaults?.commitCharacters
|
|
||||||
let config = context.state.facet(completionConfig)
|
|
||||||
|
|
||||||
return {
|
|
||||||
from, to,
|
|
||||||
options: result.items.map((item: lsp.CompletionItem) => {
|
|
||||||
let text = item.textEdit?.newText || item.textEditText || item.insertText || item.label
|
|
||||||
let option: Completion = {
|
|
||||||
label: text,
|
|
||||||
type: item.kind && kindToType[item.kind],
|
|
||||||
}
|
|
||||||
if (item.commitCharacters && item.commitCharacters != defaultCommitChars)
|
|
||||||
option.commitCharacters = item.commitCharacters
|
|
||||||
if (item.detail) option.detail = item.detail; else option.detail = "No details available"
|
|
||||||
if (item.sortText) option.sortText = item.sortText
|
|
||||||
if (item.insertTextFormat == 2 /* Snippet */) {
|
|
||||||
option.apply = (view, c, from, to) => snippet(text)(view, c, from, to)
|
|
||||||
option.label = item.label
|
|
||||||
}
|
|
||||||
option.info = item.documentation ? () => renderDocInfo(plugin, item.documentation!) : async () => {
|
|
||||||
const newItem = await resolveDocs(plugin, item);
|
|
||||||
return newItem.documentation ? renderDocInfo(plugin, newItem.documentation!) : null;
|
|
||||||
}
|
|
||||||
// if (item.documentation) option.info = () => renderDocInfo(plugin, item.documentation!);
|
|
||||||
return option
|
|
||||||
}),
|
|
||||||
commitCharacters: defaultCommitChars,
|
|
||||||
validFor: config.validFor ?? prefixRegexp(result.items),
|
|
||||||
map: (result, changes) => ({ ...result, from: changes.mapPos(result.from) }),
|
|
||||||
}
|
|
||||||
}, err => {
|
|
||||||
if ("code" in err && (err as lsp.ResponseError).code == -32800 /* RequestCancelled */)
|
|
||||||
return null
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function completionResultRange(cx: CompletionContext, result: lsp.CompletionList): { from: number, to: number } {
|
|
||||||
if (!result.items.length) return { from: cx.pos, to: cx.pos }
|
|
||||||
let defaultRange = result.itemDefaults?.editRange, item0 = result.items[0]
|
|
||||||
let range = defaultRange ? ("insert" in defaultRange ? defaultRange.insert : defaultRange)
|
|
||||||
: item0.textEdit ? ("range" in item0.textEdit ? item0.textEdit.range : item0.textEdit.insert)
|
|
||||||
: null
|
|
||||||
if (!range) return cx.state.wordAt(cx.pos) || { from: cx.pos, to: cx.pos }
|
|
||||||
let line = cx.state.doc.lineAt(cx.pos)
|
|
||||||
return { from: line.from + range.start.character, to: line.from + range.end.character }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDocInfo(plugin: LSPPlugin, doc: string | lsp.MarkupContent) {
|
|
||||||
let elt = document.createElement("div")
|
|
||||||
elt.className = "cm-lsp-documentation cm-lsp-completion-documentation"
|
|
||||||
elt.innerHTML = plugin.docToHTML(doc)
|
|
||||||
return elt
|
|
||||||
}
|
|
||||||
|
|
||||||
const kindToType: { [kind: number]: string } = {
|
|
||||||
1: "text", // Text
|
|
||||||
2: "method", // Method
|
|
||||||
3: "function", // Function
|
|
||||||
4: "class", // Constructor
|
|
||||||
5: "property", // Field
|
|
||||||
6: "variable", // Variable
|
|
||||||
7: "class", // Class
|
|
||||||
8: "interface", // Interface
|
|
||||||
9: "namespace", // Module
|
|
||||||
10: "property", // Property
|
|
||||||
11: "keyword", // Unit
|
|
||||||
12: "constant", // Value
|
|
||||||
13: "constant", // Enum
|
|
||||||
14: "keyword", // Keyword
|
|
||||||
16: "constant", // Color
|
|
||||||
20: "constant", // EnumMember
|
|
||||||
21: "constant", // Constant
|
|
||||||
22: "class", // Struct
|
|
||||||
25: "type" // TypeParameter
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
import type * as lsp from "vscode-languageserver-protocol"
|
|
||||||
import { EditorView, Command, KeyBinding } from "@codemirror/view"
|
|
||||||
import { LSPPlugin } from "@codemirror/lsp-client";
|
|
||||||
|
|
||||||
import { addEditor } from "../editorgrid";
|
|
||||||
import { OpenFile } from "../filestate";
|
|
||||||
|
|
||||||
function getDefinition(plugin: LSPPlugin, pos: number) {
|
|
||||||
return plugin.client.request<lsp.DefinitionParams, lsp.Location | lsp.Location[] | null>("textDocument/definition", {
|
|
||||||
textDocument: { uri: plugin.uri },
|
|
||||||
position: plugin.toPosition(pos)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeclaration(plugin: LSPPlugin, pos: number) {
|
|
||||||
return plugin.client.request<lsp.DeclarationParams, lsp.Location | lsp.Location[] | null>("textDocument/declaration", {
|
|
||||||
textDocument: { uri: plugin.uri },
|
|
||||||
position: plugin.toPosition(pos)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTypeDefinition(plugin: LSPPlugin, pos: number) {
|
|
||||||
return plugin.client.request<lsp.TypeDefinitionParams, lsp.Location | lsp.Location[] | null>("textDocument/typeDefinition", {
|
|
||||||
textDocument: { uri: plugin.uri },
|
|
||||||
position: plugin.toPosition(pos)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImplementation(plugin: LSPPlugin, pos: number) {
|
|
||||||
return plugin.client.request<lsp.ImplementationParams, lsp.Location | lsp.Location[] | null>("textDocument/implementation", {
|
|
||||||
textDocument: { uri: plugin.uri },
|
|
||||||
position: plugin.toPosition(pos)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function jumpToOrigin(view: EditorView, type: { get: typeof getDefinition, capability: keyof lsp.ServerCapabilities }): boolean {
|
|
||||||
const plugin = LSPPlugin.get(view);
|
|
||||||
const hasCapability = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities[type.capability] : null;
|
|
||||||
if (!plugin || !hasCapability) return false
|
|
||||||
plugin.client.sync()
|
|
||||||
plugin.client.withMapping(mapping => type.get(plugin, view.state.selection.main.head).then(async response => {
|
|
||||||
if (!response) return
|
|
||||||
let loc = Array.isArray(response) ? response[0] : response;
|
|
||||||
const path = new URL(loc.uri).pathname;
|
|
||||||
const target = addEditor(await OpenFile.openFile(path));
|
|
||||||
const pos = mapping.getMapping(loc.uri) ? mapping.mapPosition(loc.uri, loc.range.start) : plugin.fromPosition(loc.range.start, target.view.state.doc);
|
|
||||||
target.view.dispatch({selection: {anchor: pos}, scrollIntoView: true, userEvent: "select.definition"});
|
|
||||||
}, error => plugin.reportError("Find definition failed", error)))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump to the definition of the symbol at the cursor. To support
|
|
||||||
/// cross-file jumps, you'll need to implement
|
|
||||||
/// [`Workspace.displayFile`](#lsp-client.Workspace.displayFile).
|
|
||||||
export const jumpToDefinition: Command = view => jumpToOrigin(view, {
|
|
||||||
get: getDefinition,
|
|
||||||
capability: "definitionProvider"
|
|
||||||
})
|
|
||||||
|
|
||||||
/// Jump to the declaration of the symbol at the cursor.
|
|
||||||
export const jumpToDeclaration: Command = view => jumpToOrigin(view, {
|
|
||||||
get: getDeclaration,
|
|
||||||
capability: "declarationProvider"
|
|
||||||
})
|
|
||||||
|
|
||||||
/// Jump to the type definition of the symbol at the cursor.
|
|
||||||
export const jumpToTypeDefinition: Command = view => jumpToOrigin(view, {
|
|
||||||
get: getTypeDefinition,
|
|
||||||
capability: "typeDefinitionProvider"
|
|
||||||
})
|
|
||||||
|
|
||||||
/// Jump to the implementation of the symbol at the cursor.
|
|
||||||
export const jumpToImplementation: Command = view => jumpToOrigin(view, {
|
|
||||||
get: getImplementation,
|
|
||||||
capability: "implementationProvider"
|
|
||||||
})
|
|
||||||
|
|
||||||
/// Binds F12 to [`jumpToDefinition`](#lsp-client.jumpToDefinition).
|
|
||||||
export const jumpToDefinitionKeymap: readonly KeyBinding[] = [
|
|
||||||
{ key: "F12", run: jumpToDefinition, preventDefault: true },
|
|
||||||
]
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
import * as lsp from "vscode-languageserver-protocol";
|
|
||||||
import { StateField, StateEffect } from "@codemirror/state";
|
|
||||||
import { showPanel, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
|
||||||
import { LSPPlugin, LSPClientExtension } from "@codemirror/lsp-client";
|
|
||||||
import { OpenFile } from "../filestate";
|
|
||||||
import { Text } from "@codemirror/state";
|
|
||||||
import { applyWorkspaceEdit } from "../lsp";
|
|
||||||
|
|
||||||
import van from "vanjs-core";
|
|
||||||
const v = van.tags;
|
|
||||||
|
|
||||||
type CodeAction = (lsp.CodeAction | lsp.Command);
|
|
||||||
|
|
||||||
const setCodeActions = StateEffect.define<CodeAction[]>();
|
|
||||||
|
|
||||||
const codeActionState = StateField.define<CodeAction[]>({
|
|
||||||
create: () => null,
|
|
||||||
update(value, tr) {
|
|
||||||
for (let e of tr.effects) {
|
|
||||||
if (e.is(setCodeActions)) value = e.value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
provide: f => showPanel.from(f, ca => ca ? createCodeActionPanel : null),
|
|
||||||
});
|
|
||||||
|
|
||||||
function createCodeActionPanel(view: EditorView) {
|
|
||||||
const plugin = LSPPlugin.get(view);
|
|
||||||
const actions = view.state.field(codeActionState);
|
|
||||||
const mapping = plugin.client.workspaceMapping();
|
|
||||||
// TODO cleanup mapping when done
|
|
||||||
// TODO use codemirror/view.showDialog instead
|
|
||||||
const list = v.ul(actions.map(a => {
|
|
||||||
if (!Object.hasOwn(a, "edit")) return null;
|
|
||||||
const action = a as lsp.CodeAction;
|
|
||||||
// TODO action resolving
|
|
||||||
const onclick = () => {
|
|
||||||
applyWorkspaceEdit(mapping, plugin.client.workspace, action.edit, "codeaction");
|
|
||||||
view.dispatch({effects: setCodeActions.of(null)});
|
|
||||||
}
|
|
||||||
return v.li(v.a({onclick}, action.title));
|
|
||||||
}));
|
|
||||||
const closeBtn = v.button({
|
|
||||||
class: "absolute top-0 right-5",
|
|
||||||
type: "button",
|
|
||||||
name: "close",
|
|
||||||
"aria-label": view.state.phrase("close"),
|
|
||||||
onclick: () => view.dispatch({effects: setCodeActions.of(null)}),
|
|
||||||
}, "×");
|
|
||||||
const dom = v.div(list, closeBtn);
|
|
||||||
return { top: false, dom };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSeverity(sev: lsp.DiagnosticSeverity) {
|
|
||||||
return sev == 1
|
|
||||||
? "error"
|
|
||||||
: sev == 2
|
|
||||||
? "warning"
|
|
||||||
: sev == 3
|
|
||||||
? "info"
|
|
||||||
: "hint";
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoSync = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
pending: any | null = null;
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged) {
|
|
||||||
if (this.pending != null) clearTimeout(this.pending);
|
|
||||||
this.pending = setTimeout(() => {
|
|
||||||
this.pending = null;
|
|
||||||
const plugin = LSPPlugin.get(update.view);
|
|
||||||
if (plugin) plugin.client.sync();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
destroy() {
|
|
||||||
if (this.pending != null) clearTimeout(this.pending);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function fromPosition(doc: Text, pos: lsp.Position): number {
|
|
||||||
const line = doc.line(pos.line + 1);
|
|
||||||
return line.from + pos.character;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchCodeActionsForDiagnostic(plugin: LSPPlugin, diag: lsp.Diagnostic): Promise<CodeAction[]> {
|
|
||||||
const hasCap = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities["codeActionProvider"] : null;
|
|
||||||
if (hasCap === false) return Promise.resolve(null);
|
|
||||||
const params: lsp.CodeActionParams = {
|
|
||||||
textDocument: { uri: plugin.uri },
|
|
||||||
range: diag.range,
|
|
||||||
context: {
|
|
||||||
diagnostics: [diag], // TODO multiple?
|
|
||||||
triggerKind: lsp.CodeActionTriggerKind.Invoked,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plugin.client.request<
|
|
||||||
lsp.CodeActionParams,
|
|
||||||
(lsp.Command | lsp.CodeAction)[]
|
|
||||||
>(
|
|
||||||
"textDocument/codeAction",
|
|
||||||
params
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serverDiagnostics(): LSPClientExtension {
|
|
||||||
return {
|
|
||||||
clientCapabilities: {
|
|
||||||
textDocument: { publishDiagnostics: { versionSupport: true } },
|
|
||||||
},
|
|
||||||
notificationHandlers: {
|
|
||||||
"textDocument/publishDiagnostics": (
|
|
||||||
client,
|
|
||||||
params: lsp.PublishDiagnosticsParams,
|
|
||||||
) => {
|
|
||||||
const file = client.workspace.getFile(params.uri) as OpenFile;
|
|
||||||
if (!file) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (params.version != null && params.version != file.version) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
file.setDiagnostics(
|
|
||||||
params.diagnostics.map((item) => ({
|
|
||||||
from: file.changes.mapPos(
|
|
||||||
fromPosition(file.doc, item.range.start),
|
|
||||||
),
|
|
||||||
to: file.changes.mapPos(
|
|
||||||
fromPosition(file.doc, item.range.end),
|
|
||||||
),
|
|
||||||
severity: toSeverity(item.severity ?? 1),
|
|
||||||
message: item.message,
|
|
||||||
actions: [{
|
|
||||||
name: "Solve",
|
|
||||||
apply: async (view) => {
|
|
||||||
const plugin = LSPPlugin.get(view);
|
|
||||||
const a = await fetchCodeActionsForDiagnostic(plugin, item);
|
|
||||||
const effects = setCodeActions.of(a);
|
|
||||||
view.dispatch({ effects });
|
|
||||||
},
|
|
||||||
}]
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
editorExtension: [autoSync, codeActionState],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,30 +1,50 @@
|
||||||
// import "./pico.jade.css";
|
/**
|
||||||
|
* This file will automatically be loaded by vite and run in the "renderer" context.
|
||||||
|
* To learn more about the differences between the "main" and the "renderer" context in
|
||||||
|
* Electron, visit:
|
||||||
|
*
|
||||||
|
* https://electronjs.org/docs/tutorial/process-model
|
||||||
|
*
|
||||||
|
* By default, Node.js integration in this file is disabled. When enabling Node.js integration
|
||||||
|
* in a renderer process, please be aware of potential security implications. You can read
|
||||||
|
* more about security risks here:
|
||||||
|
*
|
||||||
|
* https://electronjs.org/docs/tutorial/security
|
||||||
|
*
|
||||||
|
* To enable Node.js integration in this file, open up `main.ts` and enable the `nodeIntegration`
|
||||||
|
* flag:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* // Create the browser window.
|
||||||
|
* mainWindow = new BrowserWindow({
|
||||||
|
* width: 800,
|
||||||
|
* height: 600,
|
||||||
|
* webPreferences: {
|
||||||
|
* nodeIntegration: true
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "./pico.jade.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
import van from "vanjs-core";
|
import van from "vanjs-core";
|
||||||
|
import * as vanX from "vanjs-ext";
|
||||||
const v = van.tags;
|
const v = van.tags;
|
||||||
|
|
||||||
import { FolderTreeView } from "./foldernav";
|
import { EditorColumn } from "./editor";
|
||||||
import { EditorTabs, addTab, addEditor } from "./editorgrid";
|
import { NavBar } from "./foldernav";
|
||||||
import * as u from "./utils";
|
|
||||||
import { OpenFile } from "./filestate";
|
|
||||||
|
|
||||||
function newFile() {
|
// Create and mount editor list
|
||||||
const file = new OpenFile({});
|
const editors = vanX.reactive([]);
|
||||||
addEditor(file);
|
vanX.list(document.getElementById("editorGrid"), editors, (v) => v.val.dom);
|
||||||
|
|
||||||
|
function addView() {
|
||||||
|
editors.push(vanX.noreactive(new EditorColumn()));
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = v.div(
|
document.getElementById("addEditor")?.addEventListener("click", addView);
|
||||||
{ class: "h-screen max-h-screen w-screen max-w-screen flex" },
|
van.add(document.querySelector("aside nav"), NavBar);
|
||||||
v.aside(
|
|
||||||
{
|
|
||||||
class: "flex-none resize-x overflow-x-hidden overflow-y-scroll w-3xs min-w-32",
|
|
||||||
},
|
|
||||||
u.InlineButton(addTab, "Add Tab", "+Tab"),
|
|
||||||
u.InlineButton(newFile, "Add Editor", "+File"),
|
|
||||||
FolderTreeView,
|
|
||||||
),
|
|
||||||
EditorTabs,
|
|
||||||
);
|
|
||||||
|
|
||||||
van.add(document.body, app);
|
addView();
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { Displayable } from "./displayable";
|
|
||||||
import * as xterm from "@xterm/xterm";
|
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
|
||||||
import van, { State } from "vanjs-core";
|
|
||||||
const v = van.tags;
|
|
||||||
|
|
||||||
export class Terminal extends Displayable {
|
|
||||||
term: xterm.Terminal;
|
|
||||||
currentTitle: State<string> = van.state("Terminal");
|
|
||||||
dom: HTMLElement;
|
|
||||||
private terminalId: string | null = null;
|
|
||||||
private fitAddon: FitAddon;
|
|
||||||
private resizeObserver: ResizeObserver;
|
|
||||||
private unsubTerminalData?: () => void;
|
|
||||||
private unsubTerminalExit?: () => void;
|
|
||||||
|
|
||||||
title(): string {
|
|
||||||
return this.currentTitle.val;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.term = new xterm.Terminal({
|
|
||||||
// cursorBlink: true,
|
|
||||||
// fontSize: 14,
|
|
||||||
// fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fitAddon = new FitAddon();
|
|
||||||
this.term.loadAddon(this.fitAddon);
|
|
||||||
|
|
||||||
this.dom = v.div({
|
|
||||||
class: "h-full w-2xl resize-x overflow-x-hidden scroll-m-[100px]",
|
|
||||||
});
|
|
||||||
|
|
||||||
const loaded = van.state(false);
|
|
||||||
|
|
||||||
van.derive(() => {
|
|
||||||
if (loaded.val) {
|
|
||||||
this.initializeTerminal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
loaded.val = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initializeTerminal() {
|
|
||||||
this.term.open(this.dom);
|
|
||||||
|
|
||||||
// Create terminal in main process
|
|
||||||
try {
|
|
||||||
this.terminalId = await window.electronAPI.createTerminal();
|
|
||||||
|
|
||||||
// Set up data handling (subscribe/unsubscribe)
|
|
||||||
this.unsubTerminalData = window.electronAPI.onTerminalData(
|
|
||||||
this.terminalId,
|
|
||||||
(data) => this.term.write(data),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.unsubTerminalExit = window.electronAPI.onTerminalExit(
|
|
||||||
this.terminalId,
|
|
||||||
(exitCode) => {
|
|
||||||
this.term.writeln(
|
|
||||||
`\r\n[Process exited with code ${exitCode}]\r\nPress any key to close...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.term.onData(() => this.close());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle user input
|
|
||||||
this.term.onData((data) => {
|
|
||||||
if (this.terminalId) {
|
|
||||||
window.electronAPI.writeToTerminal(this.terminalId, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.term.onTitleChange((title) => (this.currentTitle.val = title));
|
|
||||||
|
|
||||||
// Set up resize handling
|
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
|
||||||
this.handleResize();
|
|
||||||
});
|
|
||||||
this.resizeObserver.observe(this.dom);
|
|
||||||
|
|
||||||
// Initial fit
|
|
||||||
setTimeout(() => {
|
|
||||||
this.handleResize();
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize terminal:", error);
|
|
||||||
this.term.writeln(
|
|
||||||
"Failed to initialize terminal. Check console for details.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleResize() {
|
|
||||||
if (
|
|
||||||
this.terminalId &&
|
|
||||||
this.dom.clientWidth > 0 &&
|
|
||||||
this.dom.clientHeight > 0
|
|
||||||
) {
|
|
||||||
this.fitAddon.fit();
|
|
||||||
const { cols, rows } = this.term;
|
|
||||||
window.electronAPI.resizeTerminal(this.terminalId, cols, rows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focus() {
|
|
||||||
this.dom.scrollIntoView({ behavior: "smooth" });
|
|
||||||
this.term.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (this.terminalId) {
|
|
||||||
window.electronAPI.closeTerminal(this.terminalId);
|
|
||||||
}
|
|
||||||
if (this.resizeObserver) {
|
|
||||||
this.resizeObserver.disconnect();
|
|
||||||
}
|
|
||||||
if (this.unsubTerminalData) this.unsubTerminalData();
|
|
||||||
if (this.unsubTerminalExit) this.unsubTerminalExit();
|
|
||||||
this.term.dispose();
|
|
||||||
if (this.deleteFn) this.deleteFn();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import van from "vanjs-core";
|
|
||||||
const v = van.tags;
|
|
||||||
|
|
||||||
export const Button = (onclick: () => void, text: string) =>
|
|
||||||
v.button({ class: "bg-green-500 dark:bg-green-700 p-2", onclick }, text);
|
|
||||||
|
|
||||||
export const InlineButton = (
|
|
||||||
onclick: () => void,
|
|
||||||
title: string,
|
|
||||||
text: string,
|
|
||||||
) =>
|
|
||||||
v.button(
|
|
||||||
{
|
|
||||||
class: "mx-1 w-[2em] flex-none",
|
|
||||||
title,
|
|
||||||
onclick,
|
|
||||||
},
|
|
||||||
text,
|
|
||||||
);
|
|
||||||
|
|
@ -6,8 +6,6 @@ import fs from "fs";
|
||||||
const fsp = fs.promises;
|
const fsp = fs.promises;
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import * as chokidar from "chokidar";
|
|
||||||
|
|
||||||
type FolderTree = {
|
type FolderTree = {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -15,39 +13,6 @@ type FolderTree = {
|
||||||
children?: FolderTree[];
|
children?: FolderTree[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track the currently opened folder for security checks
|
|
||||||
let currentWorkspaceRoot: string | null = null;
|
|
||||||
let watcher: chokidar.FSWatcher | null = null;
|
|
||||||
|
|
||||||
export function getCurrentWorkspaceRoot(): string | null {
|
|
||||||
return currentWorkspaceRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to (re)create watcher and wire up IPC notifications to renderer
|
|
||||||
function ensureWatcher() {
|
|
||||||
if (watcher) return watcher;
|
|
||||||
watcher = chokidar.watch([], { ignoreInitial: true });
|
|
||||||
|
|
||||||
watcher.on("all", (event, filePath) => {
|
|
||||||
console.log("chokidar", event, filePath);
|
|
||||||
// Broadcast to all renderer windows
|
|
||||||
try {
|
|
||||||
BrowserWindow.getAllWindows().forEach((w) =>
|
|
||||||
w.webContents.send("fs:event", { event, path: filePath }),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to send fs:event to renderer:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watcher.on("error", (err) => console.error("Watcher error:", err));
|
|
||||||
|
|
||||||
return watcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track previously opened files outside the workspace
|
|
||||||
const openedFiles = new Set<string>();
|
|
||||||
|
|
||||||
async function readTree(dirPath: string): Promise<FolderTree[]> {
|
async function readTree(dirPath: string): Promise<FolderTree[]> {
|
||||||
const stats = await fsp.stat(dirPath);
|
const stats = await fsp.stat(dirPath);
|
||||||
if (!stats.isDirectory()) return [];
|
if (!stats.isDirectory()) return [];
|
||||||
|
|
@ -70,7 +35,7 @@ async function readTree(dirPath: string): Promise<FolderTree[]> {
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
@ -83,11 +48,6 @@ export async function handleOpenFolder(
|
||||||
});
|
});
|
||||||
if (!result.canceled && result.filePaths.length > 0) {
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
const folderPath = result.filePaths[0];
|
const folderPath = result.filePaths[0];
|
||||||
currentWorkspaceRoot = folderPath; // Track the opened folder
|
|
||||||
// Ensure watcher exists and add this folder to it
|
|
||||||
const w = ensureWatcher();
|
|
||||||
w.add(folderPath);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: path.basename(folderPath),
|
name: path.basename(folderPath),
|
||||||
path: folderPath,
|
path: folderPath,
|
||||||
|
|
@ -97,245 +57,3 @@ export async function handleOpenFolder(
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security helper: Check if a path is within the current workspace or previously opened
|
|
||||||
function isPathSecure(filePath: string): boolean {
|
|
||||||
const resolvedPath = path.resolve(filePath);
|
|
||||||
|
|
||||||
// Allow if within workspace
|
|
||||||
if (currentWorkspaceRoot) {
|
|
||||||
const resolvedRoot = path.resolve(currentWorkspaceRoot);
|
|
||||||
|
|
||||||
if (
|
|
||||||
resolvedPath.startsWith(resolvedRoot + path.sep) ||
|
|
||||||
resolvedPath === resolvedRoot
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow if previously opened
|
|
||||||
if (openedFiles.has(resolvedPath)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// File Operations
|
|
||||||
export async function handleReadFile(
|
|
||||||
mainWindow: BrowserWindow,
|
|
||||||
filePath?: string,
|
|
||||||
): Promise<{ content: string; path: string } | null> {
|
|
||||||
let targetPath: string;
|
|
||||||
|
|
||||||
if (filePath) {
|
|
||||||
// If path is provided, check if it's secure
|
|
||||||
if (!isPathSecure(filePath)) {
|
|
||||||
throw new Error(
|
|
||||||
"Access denied: File is outside the workspace and not previously opened",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
targetPath = filePath;
|
|
||||||
} else {
|
|
||||||
// Show file dialog
|
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
|
||||||
properties: ["openFile"],
|
|
||||||
defaultPath: currentWorkspaceRoot || undefined,
|
|
||||||
// filters: [
|
|
||||||
// { name: "All Files", extensions: ["*"] },
|
|
||||||
// {
|
|
||||||
// name: "Text Files",
|
|
||||||
// extensions: [
|
|
||||||
// "txt",
|
|
||||||
// "md",
|
|
||||||
// "js",
|
|
||||||
// "ts",
|
|
||||||
// "json",
|
|
||||||
// "html",
|
|
||||||
// "css",
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.canceled || result.filePaths.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
targetPath = result.filePaths[0];
|
|
||||||
|
|
||||||
// Track files opened via dialog to allow future access
|
|
||||||
openedFiles.add(path.resolve(targetPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await fsp.readFile(targetPath, "utf8");
|
|
||||||
return { content, path: targetPath };
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to read file: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleSaveFile(
|
|
||||||
mainWindow: BrowserWindow,
|
|
||||||
content: string,
|
|
||||||
filePath?: string,
|
|
||||||
): Promise<{ path: string } | null> {
|
|
||||||
let targetPath: string;
|
|
||||||
|
|
||||||
if (filePath) {
|
|
||||||
// If path is provided, check if it's secure
|
|
||||||
if (!isPathSecure(filePath)) {
|
|
||||||
throw new Error(
|
|
||||||
"Access denied: File is outside the workspace and not previously opened",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
targetPath = filePath;
|
|
||||||
} else {
|
|
||||||
// Show save dialog
|
|
||||||
const result = await dialog.showSaveDialog(mainWindow, {
|
|
||||||
defaultPath: currentWorkspaceRoot || undefined,
|
|
||||||
// filters: [
|
|
||||||
// { name: "All Files", extensions: ["*"] },
|
|
||||||
// {
|
|
||||||
// name: "Text Files",
|
|
||||||
// extensions: [
|
|
||||||
// "txt",
|
|
||||||
// "md",
|
|
||||||
// "js",
|
|
||||||
// "ts",
|
|
||||||
// "json",
|
|
||||||
// "html",
|
|
||||||
// "css",
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.canceled || !result.filePath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
targetPath = result.filePath;
|
|
||||||
|
|
||||||
// Track files saved via dialog (always allowed)
|
|
||||||
openedFiles.add(path.resolve(targetPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure directory exists
|
|
||||||
const dir = path.dirname(targetPath);
|
|
||||||
await fsp.mkdir(dir, { recursive: true });
|
|
||||||
|
|
||||||
await fsp.writeFile(targetPath, content, "utf8");
|
|
||||||
return { path: targetPath };
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to save file: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// export async function handleCreateFile(
|
|
||||||
// mainWindow: BrowserWindow,
|
|
||||||
// fileName: string,
|
|
||||||
// content = "",
|
|
||||||
// directory?: string,
|
|
||||||
// ): Promise<{ path: string } | null> {
|
|
||||||
// let targetDir: string;
|
|
||||||
|
|
||||||
// if (directory) {
|
|
||||||
// // If directory is provided, check if it's secure
|
|
||||||
// if (!isPathSecure(directory)) {
|
|
||||||
// throw new Error(
|
|
||||||
// "Access denied: Directory is outside the workspace",
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// targetDir = directory;
|
|
||||||
// } else if (currentWorkspaceRoot) {
|
|
||||||
// // Use current workspace root
|
|
||||||
// targetDir = currentWorkspaceRoot;
|
|
||||||
// } else {
|
|
||||||
// // Show directory selection dialog
|
|
||||||
// const result = await dialog.showOpenDialog(mainWindow, {
|
|
||||||
// properties: ["openDirectory"],
|
|
||||||
// title: "Select directory to create file in",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (result.canceled || result.filePaths.length === 0) {
|
|
||||||
// return null;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// targetDir = result.filePaths[0];
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const targetPath = path.join(targetDir, fileName);
|
|
||||||
|
|
||||||
// // Double-check the final path is secure
|
|
||||||
// if (!isPathSecure(targetPath)) {
|
|
||||||
// throw new Error("Access denied: Target path is outside the workspace");
|
|
||||||
// }
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// // Check if file already exists
|
|
||||||
// const exists = await fsp
|
|
||||||
// .access(targetPath)
|
|
||||||
// .then(() => true)
|
|
||||||
// .catch(() => false);
|
|
||||||
// if (exists) {
|
|
||||||
// // Ask user if they want to overwrite
|
|
||||||
// const choice = await dialog.showMessageBox(mainWindow, {
|
|
||||||
// type: "question",
|
|
||||||
// buttons: ["Cancel", "Overwrite"],
|
|
||||||
// defaultId: 0,
|
|
||||||
// message: `File "${fileName}" already exists. Do you want to overwrite it?`,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (choice.response === 0) {
|
|
||||||
// return null; // User cancelled
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// await fsp.writeFile(targetPath, content, "utf8");
|
|
||||||
// return { path: targetPath };
|
|
||||||
// } catch (error) {
|
|
||||||
// throw new Error(`Failed to create file: ${error.message}`);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Utility function to get current workspace info
|
|
||||||
export function getCurrentWorkspace(): { root: string | null } {
|
|
||||||
return { root: currentWorkspaceRoot };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a fresh FolderTree for the current workspace (no dialogs)
|
|
||||||
export async function getWorkspaceTree(): Promise<FolderTree | null> {
|
|
||||||
if (!currentWorkspaceRoot) return null;
|
|
||||||
return {
|
|
||||||
name: path.basename(currentWorkspaceRoot),
|
|
||||||
path: currentWorkspaceRoot,
|
|
||||||
type: "directory",
|
|
||||||
children: await readTree(currentWorkspaceRoot),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility function to get opened files (for debugging/info)
|
|
||||||
export function getOpenedFiles(): string[] {
|
|
||||||
return Array.from(openedFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show confirmation dialog
|
|
||||||
export async function showConfirmDialog(
|
|
||||||
mainWindow: BrowserWindow,
|
|
||||||
message: string,
|
|
||||||
title: string,
|
|
||||||
buttons: string[] = ["OK", "Cancel"],
|
|
||||||
): Promise<string> {
|
|
||||||
const result = await dialog.showMessageBox(mainWindow, {
|
|
||||||
type: "question",
|
|
||||||
buttons,
|
|
||||||
defaultId: 0,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
return buttons[result.response];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
/// <reference types="@electron-forge/plugin-vite/vite-env" />
|
|
||||||
|
|
||||||
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
|
||||||
declare const MAIN_WINDOW_VITE_NAME: string;
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
import {
|
|
||||||
ipcMain,
|
|
||||||
MessageChannelMain,
|
|
||||||
BrowserWindow,
|
|
||||||
MessagePortMain,
|
|
||||||
} from "electron";
|
|
||||||
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
|
|
||||||
import { getCurrentWorkspaceRoot } from "./fileOperations";
|
|
||||||
|
|
||||||
type LspEntry = {
|
|
||||||
proc: ChildProcessWithoutNullStreams;
|
|
||||||
buffer: Buffer;
|
|
||||||
ports: Set<MessagePortMain>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const lspServers = new Map<string, LspEntry>();
|
|
||||||
|
|
||||||
// simple fallback mapping for a few languages — prefer env overrides
|
|
||||||
const fallbackServerForLanguage: Record<string, string | undefined> = {
|
|
||||||
typescript: "npx typescript-language-server --log-level 4 --stdio",
|
|
||||||
python: "pylsp --check-parent-process",
|
|
||||||
haskell: "haskell-language-server-wrapper --lsp",
|
|
||||||
rust: "rust-analyzer",
|
|
||||||
cpp: "clangd",
|
|
||||||
};
|
|
||||||
|
|
||||||
function ensureLspForKey(
|
|
||||||
serverKey: string,
|
|
||||||
language: string | undefined,
|
|
||||||
root: string | undefined,
|
|
||||||
) {
|
|
||||||
if (lspServers.has(serverKey)) return lspServers.get(serverKey)!.proc;
|
|
||||||
|
|
||||||
// Determine command for this language
|
|
||||||
let raw: string | undefined = undefined;
|
|
||||||
if (language) {
|
|
||||||
const envKey = `LSP_SERVER_CMD_${language.toUpperCase()}`;
|
|
||||||
raw = process.env[envKey];
|
|
||||||
}
|
|
||||||
raw =
|
|
||||||
raw ||
|
|
||||||
process.env.LSP_SERVER_CMD ||
|
|
||||||
fallbackServerForLanguage[language || ""];
|
|
||||||
if (!raw)
|
|
||||||
throw new Error(
|
|
||||||
`No LSP server command configured for language=${language}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const parts = raw.trim().split(/\s+/);
|
|
||||||
const cmd = parts[0];
|
|
||||||
const args = parts.slice(1);
|
|
||||||
const cwd = root || getCurrentWorkspaceRoot() || process.cwd();
|
|
||||||
console.log(
|
|
||||||
"Starting LSP server:",
|
|
||||||
cmd,
|
|
||||||
args,
|
|
||||||
"cwd=",
|
|
||||||
cwd,
|
|
||||||
"serverKey=",
|
|
||||||
serverKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spawn using shell:true so PATH and shell resolution behave like a user shell.
|
|
||||||
const proc = spawn([cmd].concat(args).join(" "), {
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
cwd,
|
|
||||||
shell: true,
|
|
||||||
});
|
|
||||||
console.log("LSP server started with PID", proc.pid);
|
|
||||||
const entry: LspEntry = { proc, buffer: Buffer.alloc(0), ports: new Set() };
|
|
||||||
lspServers.set(serverKey, entry);
|
|
||||||
|
|
||||||
// Buffer stdout and parse LSP-framed messages (Content-Length headers)
|
|
||||||
proc.stdout.on("data", (chunk: Buffer) => {
|
|
||||||
entry.buffer = Buffer.concat([entry.buffer, Buffer.from(chunk)]);
|
|
||||||
// Try to parse as many messages as available
|
|
||||||
while (true) {
|
|
||||||
const headerEnd = entry.buffer.indexOf("\r\n\r\n");
|
|
||||||
if (headerEnd === -1) break;
|
|
||||||
const header = entry.buffer.subarray(0, headerEnd).toString();
|
|
||||||
const m = header.match(/Content-Length:\s*(\d+)/i);
|
|
||||||
if (!m) {
|
|
||||||
// Malformed, drop
|
|
||||||
entry.buffer = entry.buffer.subarray(headerEnd + 4);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const len = parseInt(m[1], 10);
|
|
||||||
const totalLen = headerEnd + 4 + len;
|
|
||||||
if (entry.buffer.length < totalLen) break; // wait for more
|
|
||||||
const body = entry.buffer
|
|
||||||
.subarray(headerEnd + 4, totalLen)
|
|
||||||
.toString();
|
|
||||||
// Forward body to all attached ports
|
|
||||||
try {
|
|
||||||
entry.ports.forEach((p) => {
|
|
||||||
try {
|
|
||||||
p.postMessage(body);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
"Failed to post to port on serverKey",
|
|
||||||
serverKey,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to forward LSP message to renderer", err);
|
|
||||||
}
|
|
||||||
entry.buffer = entry.buffer.subarray(totalLen);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stderr.on("data", (chunk: Buffer) => {
|
|
||||||
console.error("LSP stderr:", chunk.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on("exit", (code, signal) => {
|
|
||||||
console.log("LSP server exited", code, signal, serverKey);
|
|
||||||
lspServers.delete(serverKey);
|
|
||||||
try {
|
|
||||||
// broadcast exit event to all renderers
|
|
||||||
BrowserWindow.getAllWindows().forEach((w) =>
|
|
||||||
w.webContents.send("lsp:exit", { code, signal, serverKey }),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to broadcast LSP exit event:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return proc;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupLangServer() {
|
|
||||||
ipcMain.handle(
|
|
||||||
"lsp:connect",
|
|
||||||
async (event, opts?: { language?: string; root?: string }) => {
|
|
||||||
const sender = event.sender;
|
|
||||||
const language = opts?.language;
|
|
||||||
const root =
|
|
||||||
opts?.root || getCurrentWorkspaceRoot() || process.cwd();
|
|
||||||
const publicKey = `${language || "auto"}::${root}`; // visible to renderer
|
|
||||||
const internalKey = `${sender.id}::${publicKey}`; // unique per renderer
|
|
||||||
|
|
||||||
let proc: ChildProcessWithoutNullStreams;
|
|
||||||
try {
|
|
||||||
proc = ensureLspForKey(internalKey, language, root);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to ensure LSP server:", err);
|
|
||||||
return { ok: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a MessageChannelMain and hand one port to the renderer.
|
|
||||||
const { port1, port2 } = new MessageChannelMain();
|
|
||||||
|
|
||||||
// Ensure port1 is started (MessagePortMain has start()).
|
|
||||||
port1.start();
|
|
||||||
|
|
||||||
// When renderer posts a message on port1, forward to LSP server stdin.
|
|
||||||
port1.on("message", (arg) => {
|
|
||||||
let data: any = arg;
|
|
||||||
try {
|
|
||||||
if (arg && typeof arg === "object" && "data" in arg)
|
|
||||||
data = (arg as any).data;
|
|
||||||
} catch (e) {
|
|
||||||
data = arg;
|
|
||||||
}
|
|
||||||
if (typeof data === "string") {
|
|
||||||
// Wrap in Content-Length header
|
|
||||||
const buf = Buffer.from(data, "utf8");
|
|
||||||
const header = Buffer.from(
|
|
||||||
`Content-Length: ${buf.length}\r\n\r\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
proc.stdin.write(Buffer.concat([header, buf]));
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Failed to write to LSP stdin", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach this port to the server entry so stdout gets forwarded to it
|
|
||||||
const entry = lspServers.get(internalKey);
|
|
||||||
if (entry) entry.ports.add(port1);
|
|
||||||
|
|
||||||
// Transfer port2 to renderer along with metadata (serverKey, language)
|
|
||||||
try {
|
|
||||||
event.sender.postMessage(
|
|
||||||
"lsp:port",
|
|
||||||
{ serverKey: publicKey, language },
|
|
||||||
[port2],
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
"Failed to send LSP MessagePort to renderer:",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
return { ok: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
return { ok: true, serverKey: publicKey };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
107
src/main/main.ts
107
src/main/main.ts
|
|
@ -1,46 +1,26 @@
|
||||||
import { app, BrowserWindow, ipcMain } from "electron";
|
import { app, BrowserWindow, ipcMain } from "electron";
|
||||||
import {
|
import { handleOpenFolder } from "./fileOperations";
|
||||||
handleOpenFolder,
|
|
||||||
handleReadFile,
|
|
||||||
handleSaveFile,
|
|
||||||
// handleCreateFile,
|
|
||||||
getOpenedFiles,
|
|
||||||
showConfirmDialog,
|
|
||||||
getWorkspaceTree,
|
|
||||||
getCurrentWorkspace,
|
|
||||||
} from "./fileOperations";
|
|
||||||
import { terminalManager } from "./pty";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
import { setupLangServer } from "./langserver";
|
|
||||||
import { setMenu } from "./menu";
|
|
||||||
/// <reference types="./forge-vite-env.d.ts" />
|
|
||||||
|
|
||||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||||
if (started) {
|
if (started) {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.setName("miller");
|
|
||||||
setMenu();
|
|
||||||
|
|
||||||
const createWindow = () => {
|
const createWindow = () => {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 800,
|
||||||
height: 720,
|
height: 600,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
},
|
},
|
||||||
icon: "./resources/icon.png",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// and load the index.html of the app.
|
// and load the index.html of the app.
|
||||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||||
|
|
||||||
// Open the DevTools only in dev mode
|
|
||||||
mainWindow.webContents.openDevTools();
|
|
||||||
} else {
|
} else {
|
||||||
mainWindow.loadFile(
|
mainWindow.loadFile(
|
||||||
path.join(
|
path.join(
|
||||||
|
|
@ -49,6 +29,9 @@ const createWindow = () => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open the DevTools.
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
};
|
};
|
||||||
|
|
||||||
// This method will be called when Electron has finished
|
// This method will be called when Electron has finished
|
||||||
|
|
@ -61,84 +44,6 @@ app.whenReady().then(() => {
|
||||||
const senderWindow = BrowserWindow.fromWebContents(event.sender);
|
const senderWindow = BrowserWindow.fromWebContents(event.sender);
|
||||||
return await handleOpenFolder(senderWindow);
|
return await handleOpenFolder(senderWindow);
|
||||||
});
|
});
|
||||||
|
|
||||||
// File operation handlers
|
|
||||||
ipcMain.handle("file:read", async (event, filePath?: string) => {
|
|
||||||
const senderWindow = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
return await handleReadFile(senderWindow, filePath);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"file:save",
|
|
||||||
async (event, content: string, filePath?: string) => {
|
|
||||||
const senderWindow = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
return await handleSaveFile(senderWindow, content, filePath);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ipcMain.handle("file:create", async (event, fileName: string, content = '', directory?: string) => {
|
|
||||||
// const senderWindow = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
// return await handleCreateFile(senderWindow, fileName, content, directory);
|
|
||||||
// });
|
|
||||||
|
|
||||||
ipcMain.handle("workspace:getCurrentInfo", () => {
|
|
||||||
return getCurrentWorkspace();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle("workspace:getOpenedFiles", () => {
|
|
||||||
return getOpenedFiles();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return folder tree without showing dialogs
|
|
||||||
ipcMain.handle("workspace:getTree", async () => {
|
|
||||||
return await getWorkspaceTree();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"dialog:confirm",
|
|
||||||
async (event, message: string, title: string, buttons: string[]) => {
|
|
||||||
const senderWindow = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
return await showConfirmDialog(
|
|
||||||
senderWindow,
|
|
||||||
message,
|
|
||||||
title,
|
|
||||||
buttons,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Terminal handlers
|
|
||||||
|
|
||||||
// LSP server manager moved to src/main/langserver.ts
|
|
||||||
// It is initialized below via setupLangServer().
|
|
||||||
setupLangServer();
|
|
||||||
|
|
||||||
// Terminal handlers
|
|
||||||
ipcMain.handle(
|
|
||||||
"terminal:create",
|
|
||||||
async (event, shell?: string, args?: string[]) => {
|
|
||||||
return terminalManager.createTerminal(event, shell, args);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"terminal:resize",
|
|
||||||
async (event, id: string, cols: number, rows: number) => {
|
|
||||||
return terminalManager.resizeTerminal(id, cols, rows);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"terminal:write",
|
|
||||||
async (event, id: string, data: string) => {
|
|
||||||
return terminalManager.writeToTerminal(id, data);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipcMain.handle("terminal:close", async (event, id: string) => {
|
|
||||||
return terminalManager.closeTerminal(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
app.on("activate", function () {
|
app.on("activate", function () {
|
||||||
|
|
|
||||||
114
src/main/menu.ts
114
src/main/menu.ts
|
|
@ -1,114 +0,0 @@
|
||||||
const { shell } = require("electron/common");
|
|
||||||
const { app, Menu } = require("electron/main");
|
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
|
||||||
const menuTemplate = [
|
|
||||||
// { role: 'appMenu' }
|
|
||||||
...(isMac
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: app.name,
|
|
||||||
submenu: [
|
|
||||||
{ role: "about" },
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "services" },
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "hide" },
|
|
||||||
{ role: "hideOthers" },
|
|
||||||
{ role: "unhide" },
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "quit" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
// { role: 'fileMenu' }
|
|
||||||
{
|
|
||||||
label: "File",
|
|
||||||
submenu: [isMac ? { role: "close" } : { role: "quit" }],
|
|
||||||
},
|
|
||||||
// { role: 'editMenu' }
|
|
||||||
// {
|
|
||||||
// label: "Edit",
|
|
||||||
// submenu: [
|
|
||||||
// { role: "undo" },
|
|
||||||
// { role: "redo" },
|
|
||||||
// { type: "separator" },
|
|
||||||
// { role: "cut" },
|
|
||||||
// { role: "copy" },
|
|
||||||
// { role: "paste" },
|
|
||||||
// ...(isMac
|
|
||||||
// ? [
|
|
||||||
// { role: "pasteAndMatchStyle" },
|
|
||||||
// { role: "delete" },
|
|
||||||
// { role: "selectAll" },
|
|
||||||
// { type: "separator" },
|
|
||||||
// {
|
|
||||||
// label: "Speech",
|
|
||||||
// submenu: [
|
|
||||||
// { role: "startSpeaking" },
|
|
||||||
// { role: "stopSpeaking" },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
// : [
|
|
||||||
// { role: "delete" },
|
|
||||||
// { type: "separator" },
|
|
||||||
// { role: "selectAll" },
|
|
||||||
// ]),
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// { role: 'viewMenu' }
|
|
||||||
{
|
|
||||||
label: "View",
|
|
||||||
submenu: [
|
|
||||||
{ role: "toggleDevTools" },
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "resetZoom" },
|
|
||||||
{ role: "zoomIn" },
|
|
||||||
{ role: "zoomOut" },
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "togglefullscreen" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// { role: 'windowMenu' }
|
|
||||||
{
|
|
||||||
label: "Window",
|
|
||||||
submenu: [
|
|
||||||
{ role: "minimize" },
|
|
||||||
{ role: "zoom" },
|
|
||||||
...(isMac
|
|
||||||
? [
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "front" },
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "window" },
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "help",
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Learn More",
|
|
||||||
click: async () => {
|
|
||||||
const { shell } = require("electron");
|
|
||||||
await shell.openExternal("https://electronjs.org");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(!isMac
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
role: "about",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function setMenu() {
|
|
||||||
const menu = Menu.buildFromTemplate(menuTemplate);
|
|
||||||
Menu.setApplicationMenu(menu);
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import * as pty from "node-pty";
|
|
||||||
import { getCurrentWorkspaceRoot } from "./fileOperations";
|
|
||||||
|
|
||||||
export interface TerminalInstance {
|
|
||||||
ptyProcess: pty.IPty;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TerminalManager {
|
|
||||||
private terminals: Map<string, TerminalInstance> = new Map();
|
|
||||||
private nextId: number = 1;
|
|
||||||
|
|
||||||
createTerminal(
|
|
||||||
event: Electron.IpcMainInvokeEvent,
|
|
||||||
shell?: string,
|
|
||||||
args?: string[],
|
|
||||||
): string {
|
|
||||||
const id = `terminal-${this.nextId++}`;
|
|
||||||
|
|
||||||
// Default shell based on platform
|
|
||||||
const defaultShell =
|
|
||||||
process.platform === "win32" ? "powershell.exe" : "/bin/bash";
|
|
||||||
const shellToUse = shell || defaultShell;
|
|
||||||
|
|
||||||
const ptyProcess = pty.spawn(shellToUse, args || [], {
|
|
||||||
name: "xterm-color",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
cwd: getCurrentWorkspaceRoot() || process.cwd(),
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
|
|
||||||
const terminal: TerminalInstance = {
|
|
||||||
ptyProcess,
|
|
||||||
};
|
|
||||||
|
|
||||||
ptyProcess.onData((data) => {
|
|
||||||
event.sender.send("terminal:data", id, data);
|
|
||||||
});
|
|
||||||
|
|
||||||
ptyProcess.onExit(({ exitCode }) => {
|
|
||||||
event.sender.send("terminal:exit", id, exitCode);
|
|
||||||
this.terminals.delete(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.terminals.set(id, terminal);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
resizeTerminal(id: string, cols: number, rows: number): boolean {
|
|
||||||
const terminal = this.terminals.get(id);
|
|
||||||
if (terminal) {
|
|
||||||
terminal.ptyProcess.resize(cols, rows);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeToTerminal(id: string, data: string): boolean {
|
|
||||||
const terminal = this.terminals.get(id);
|
|
||||||
if (terminal) {
|
|
||||||
terminal.ptyProcess.write(data);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeTerminal(id: string): boolean {
|
|
||||||
const terminal = this.terminals.get(id);
|
|
||||||
if (terminal) {
|
|
||||||
terminal.ptyProcess.kill();
|
|
||||||
this.terminals.delete(id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const terminalManager = new TerminalManager();
|
|
||||||
139
src/preload.ts
139
src/preload.ts
|
|
@ -4,146 +4,7 @@
|
||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import type { FolderTree } from "./types/global";
|
import type { FolderTree } from "./types/global";
|
||||||
|
|
||||||
// Centralized routing for terminal events: keep a single ipcRenderer listener
|
|
||||||
// and forward events to subscribed callbacks. Each `onTerminal*` returns an
|
|
||||||
// unsubscribe function so individual renderer components can remove only
|
|
||||||
// their own listeners.
|
|
||||||
const terminalDataCallbacks = new Map<string, (data: string) => void>();
|
|
||||||
const terminalExitCallbacks = new Map<string, (exitCode: number) => void>();
|
|
||||||
|
|
||||||
ipcRenderer.on("terminal:data", (_ev, id: string, data: string) => {
|
|
||||||
const cb = terminalDataCallbacks.get(id);
|
|
||||||
if (cb) cb(data);
|
|
||||||
else console.warn(`No data callback for terminal ${id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcRenderer.on("terminal:exit", (_ev, id: string, exitCode: number) => {
|
|
||||||
const cb = terminalExitCallbacks.get(id);
|
|
||||||
if (cb) cb(exitCode);
|
|
||||||
else console.warn(`No exit callback for terminal ${id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
openFolder: () =>
|
openFolder: () =>
|
||||||
ipcRenderer.invoke("dialog:openFolder") as Promise<FolderTree | null>,
|
ipcRenderer.invoke("dialog:openFolder") as Promise<FolderTree | null>,
|
||||||
|
|
||||||
// File operations
|
|
||||||
readFile: (filePath?: string) =>
|
|
||||||
ipcRenderer.invoke("file:read", filePath) as Promise<{
|
|
||||||
content: string;
|
|
||||||
path: string;
|
|
||||||
} | null>,
|
|
||||||
|
|
||||||
saveFile: (content: string, filePath?: string) =>
|
|
||||||
ipcRenderer.invoke("file:save", content, filePath) as Promise<{
|
|
||||||
path: string;
|
|
||||||
} | null>,
|
|
||||||
|
|
||||||
// createFile: (fileName: string, content = "", directory?: string) =>
|
|
||||||
// ipcRenderer.invoke(
|
|
||||||
// "file:create",
|
|
||||||
// fileName,
|
|
||||||
// content,
|
|
||||||
// directory,
|
|
||||||
// ) as Promise<{ path: string } | null>,
|
|
||||||
|
|
||||||
getCurrentWorkspace: () =>
|
|
||||||
ipcRenderer.invoke("workspace:getCurrentInfo") as Promise<{
|
|
||||||
root: string | null;
|
|
||||||
}>,
|
|
||||||
|
|
||||||
getOpenedFiles: () =>
|
|
||||||
ipcRenderer.invoke("workspace:getOpenedFiles") as Promise<string[]>,
|
|
||||||
|
|
||||||
// Get the full workspace tree without triggering dialogs
|
|
||||||
getWorkspaceTree: () =>
|
|
||||||
ipcRenderer.invoke("workspace:getTree") as Promise<FolderTree | null>,
|
|
||||||
|
|
||||||
showConfirmDialog: (message: string, title: string, buttons: string[]) =>
|
|
||||||
ipcRenderer.invoke(
|
|
||||||
"dialog:confirm",
|
|
||||||
message,
|
|
||||||
title,
|
|
||||||
buttons,
|
|
||||||
) as Promise<string>,
|
|
||||||
|
|
||||||
// Terminal operations
|
|
||||||
createTerminal: (shell?: string, args?: string[]) =>
|
|
||||||
ipcRenderer.invoke("terminal:create", shell, args) as Promise<string>,
|
|
||||||
|
|
||||||
resizeTerminal: (id: string, cols: number, rows: number) =>
|
|
||||||
ipcRenderer.invoke(
|
|
||||||
"terminal:resize",
|
|
||||||
id,
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
) as Promise<boolean>,
|
|
||||||
|
|
||||||
writeToTerminal: (id: string, data: string) =>
|
|
||||||
ipcRenderer.invoke("terminal:write", id, data) as Promise<boolean>,
|
|
||||||
|
|
||||||
closeTerminal: (id: string) =>
|
|
||||||
ipcRenderer.invoke("terminal:close", id) as Promise<boolean>,
|
|
||||||
|
|
||||||
// Terminal events (subscribe/unsubscribe)
|
|
||||||
onTerminalData: (id: string, callback: (data: string) => void) => {
|
|
||||||
terminalDataCallbacks.set(id, callback);
|
|
||||||
return () => terminalDataCallbacks.delete(id);
|
|
||||||
},
|
|
||||||
|
|
||||||
onTerminalExit: (id: string, callback: (exitCode: number) => void) => {
|
|
||||||
terminalExitCallbacks.set(id, callback);
|
|
||||||
return () => terminalExitCallbacks.delete(id);
|
|
||||||
},
|
|
||||||
|
|
||||||
// FS events subscription
|
|
||||||
onFsEvent: (callback: (ev: { event: string; path: string }) => void) => {
|
|
||||||
ipcRenderer.on(
|
|
||||||
"fs:event",
|
|
||||||
(_ev, payload: { event: string; path: string }) => {
|
|
||||||
callback(payload);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// LSP connect: request a MessagePort connected to a language server which
|
|
||||||
// is spawned in the main process. Returns a `MessagePort` that can be used
|
|
||||||
// for bidirectional communication (postMessage/onmessage).
|
|
||||||
connectLsp: async (opts?: { language?: string; root?: string }) => {
|
|
||||||
// Request the main process for a MessagePort. When it arrives we
|
|
||||||
// transfer it into the page (main world) using window.postMessage so
|
|
||||||
// the page can receive the actual MessagePort object (contextBridge
|
|
||||||
// does not allow direct transfer of MessagePort objects via return
|
|
||||||
// values). The main process will include metadata `{ serverKey, language }`
|
|
||||||
// when posting the port so we can forward that on to the page.
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
ipcRenderer.once("lsp:port", (event, payload) => {
|
|
||||||
const ports = event.ports as MessagePort[];
|
|
||||||
if (ports && ports.length > 0) {
|
|
||||||
try {
|
|
||||||
// Transfer port into the page context along with metadata.
|
|
||||||
window.postMessage(
|
|
||||||
{
|
|
||||||
source: "electron-lsp",
|
|
||||||
serverKey: payload.serverKey,
|
|
||||||
language: payload.language,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
[ports[0]],
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reject(new Error("No MessagePort received from main"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
ipcRenderer.invoke("lsp:connect", opts);
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,70 +13,7 @@ declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electronAPI: {
|
electronAPI: {
|
||||||
openFolder: () => Promise<FolderTree | null>;
|
openFolder: () => Promise<FolderTree | null>;
|
||||||
|
// Add other methods as needed
|
||||||
// File operations
|
|
||||||
readFile: (
|
|
||||||
filePath?: string,
|
|
||||||
) => Promise<{ content: string; path: string } | null>;
|
|
||||||
saveFile: (
|
|
||||||
content: string,
|
|
||||||
filePath?: string,
|
|
||||||
) => Promise<{ path: string } | null>;
|
|
||||||
// createFile: (fileName: string, content?: string, directory?: string) => Promise<{ path: string } | null>;
|
|
||||||
|
|
||||||
// Workspace info
|
|
||||||
getCurrentWorkspace: () => Promise<{ root: string | null }>;
|
|
||||||
getOpenedFiles: () => Promise<string[]>;
|
|
||||||
// Get workspace tree without dialogs
|
|
||||||
getWorkspaceTree: () => Promise<FolderTree | null>;
|
|
||||||
|
|
||||||
// Dialog operations
|
|
||||||
showConfirmDialog: (
|
|
||||||
message: string,
|
|
||||||
title: string,
|
|
||||||
buttons: string[],
|
|
||||||
) => Promise<string>;
|
|
||||||
|
|
||||||
// Terminal operations
|
|
||||||
createTerminal: (
|
|
||||||
shell?: string,
|
|
||||||
args?: string[],
|
|
||||||
) => Promise<string>;
|
|
||||||
resizeTerminal: (
|
|
||||||
id: string,
|
|
||||||
cols: number,
|
|
||||||
rows: number,
|
|
||||||
) => Promise<boolean>;
|
|
||||||
writeToTerminal: (id: string, data: string) => Promise<boolean>;
|
|
||||||
closeTerminal: (id: string) => Promise<boolean>;
|
|
||||||
onTerminalData: (
|
|
||||||
id: string,
|
|
||||||
callback: (data: string) => void,
|
|
||||||
) => () => void;
|
|
||||||
onTerminalExit: (
|
|
||||||
id: string,
|
|
||||||
callback: (exitCode: number) => void,
|
|
||||||
) => () => void;
|
|
||||||
removeAllTerminalListeners: () => void;
|
|
||||||
// Filesystem events
|
|
||||||
onFsEvent: (
|
|
||||||
callback: (ev: {
|
|
||||||
event: string;
|
|
||||||
path: string;
|
|
||||||
}) => void | Promise<void>,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
// Request that the main process create (or reuse) an LSP server and
|
|
||||||
// transfer a MessagePort into the page context. Because the
|
|
||||||
// ContextBridge cannot directly return MessagePort objects, this
|
|
||||||
// function resolves once the port has been transferred to the page
|
|
||||||
// via `window.postMessage` and the page should listen for a
|
|
||||||
// message with `{ source: 'electron-lsp' }` and take the transferred
|
|
||||||
// port from `event.ports[0]`.
|
|
||||||
connectLsp: (opts?: {
|
|
||||||
language?: string;
|
|
||||||
root?: string;
|
|
||||||
}) => Promise<any>;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
// https://vitejs.dev/config
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [tailwindcss()],
|
|
||||||
optimizeDeps: {
|
|
||||||
exclude: ["node-pty"],
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
rollupOptions: {
|
|
||||||
external: ["node-pty"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config
|
||||||
|
export default defineConfig({});
|
||||||
Loading…
Reference in New Issue