Compare commits

..

43 Commits

Author SHA1 Message Date
Quinten Kock 4352954991 Extremely basic documentChanges handling 2025-12-29 19:28:42 +01:00
Quinten Kock a884736063 Early codeaction support 2025-12-29 16:07:45 +01:00
Quinten Kock c2ebaa17b8 Completion resolving 2025-12-22 00:26:53 +01:00
Quinten Kock b058ae8a6f Open definitions in new column 2025-12-06 22:23:34 +01:00
Quinten Kock 4a245fbe9e Make pylsp run with check-parent-process 2025-12-06 05:10:46 +01:00
Quinten Kock 54af507f1d Bump version to 0.2.2 2025-12-03 04:22:13 +01:00
Quinten Kock ab73f22dc5 Add open path to window title 2025-12-03 04:21:47 +01:00
Quinten Kock 04e5d57a22 fix lsp syncing 2025-12-03 04:20:02 +01:00
Quinten Kock 954827a0d6 Change default size on open 2025-12-03 01:12:07 +01:00
Quinten Kock 4a3e78e56c Fix undo cursor jumps and formatting 2025-12-03 01:10:39 +01:00
Quinten Kock a570acf962 Fix lints in diagnostics.ts 2025-12-03 00:25:06 +01:00
Quinten Kock 27f2b18ac1 Add dark mode, styling fixes 2025-12-03 00:21:24 +01:00
Quinten Kock b555937054 Make sure resized displayables are fully in view 2025-12-02 23:42:54 +01:00
Quinten Kock 38698c24ed Add lint gutter 2025-12-02 23:41:35 +01:00
Quinten Kock 24aaf68184 Add custom menu 2025-12-02 23:34:45 +01:00
Quinten Kock 019f0a2023 Give focused tab a header color 2025-12-02 23:27:40 +01:00
Quinten Kock db5f33e961 Fix diagnostic propagation 2025-12-02 23:22:39 +01:00
Quinten Kock 0e70335791 Fix lsp file sync 2025-12-02 22:20:17 +01:00
Quinten Kock d6d49b0067 Merge pull request 'Basic LSP support' (#2) from lsp into main
Reviewed-on: #2
2025-12-02 21:26:56 +01:00
Quinten Kock b738e9aab4 Fix deprecation warnings 2025-12-02 21:25:45 +01:00
Quinten Kock e898bd91f4 Add keyboard-driven navigation within tabs 2025-12-02 20:53:28 +01:00
Quinten Kock aecff9f546 Fix state sync 2025-12-01 12:52:42 +01:00
Quinten Kock 9f3befdb61 Fix file closing 2025-12-01 02:35:33 +01:00
Quinten Kock c44971ff23 Small cleanup 2025-12-01 02:29:05 +01:00
Quinten Kock c0fed59548 More vibe-coding (for lsp/Workspace) 2025-12-01 02:09:21 +01:00
Quinten Kock dc766cbfa3 Fix keyboard shortcuts and remove llm garbage 2025-12-01 00:02:04 +01:00
Quinten Kock 94454968e5 Vibe-code LSP support 2025-11-30 04:39:33 +01:00
Quinten Kock 2f3d640ffb Add Displayable abstract class 2025-11-30 03:35:25 +01:00
Quinten Kock d9584e7543 Fix types and formatting 2025-11-30 02:49:23 +01:00
Quinten Kock b7f51e099e Set indentUnit to 4 spaces 2025-11-30 02:49:06 +01:00
Quinten Kock 7291bbee1b Make closing a Displayable focus a neighbor 2025-11-30 02:37:15 +01:00
Quinten Kock becd1483df Enable folder-refresh button 2025-11-30 02:15:22 +01:00
Quinten Kock d9d856e269 Fix focusing children of editors; add focus() to type 2025-11-30 02:06:20 +01:00
Quinten Kock 1299590f5d Attempt to fix building (esp. .desktop file matching on Wayland) 2025-11-30 01:59:11 +01:00
Quinten Kock 4326199454 Fix folder refresh debouncing 2025-11-30 01:58:20 +01:00
Quinten Kock f0520ebbdc Add basic completion support, increase editor/term sizes 2025-11-30 00:56:48 +01:00
Quinten Kock 8d935eb90e Improve scrolling behaviour
- Focusing a document or terminal scrolls it into view
- Automatic scrolling leaves some room around the target
- Aside: fix terminal shortcut giving input to xtermjs
2025-11-30 00:40:27 +01:00
Quinten Kock 62d5af3b1e Open terminal in currently active workspace 2025-11-30 00:04:33 +01:00
Quinten Kock 143576ba68 Fix packaging 2025-11-29 23:12:44 +01:00
Quinten Kock 185f44e4ec Bump dependencies and fix README 2025-11-29 17:44:14 +01:00
Quinten Kock 5ac4d3d7cf Fix: terminal focus-on-open and exit message 2025-11-29 17:37:14 +01:00
Quinten Kock d020f41b89 Implement basic file and folder watching 2025-11-26 13:33:10 +01:00
Quinten Kock 238ca8c812 Add chokidar to repo 2025-11-24 22:43:12 +01:00
25 changed files with 1862 additions and 124 deletions

1
.ignore Normal file
View File

@ -0,0 +1 @@
package.lock.json

View File

@ -1,4 +1,11 @@
# 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.
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

View File

@ -13,13 +13,17 @@ const config: ForgeConfig = {
asar: true,
icon: "res/icon",
extraResource: "res/icon.png",
ignore: ["dist", "src", "res",], // Workaround for https://github.com/electron/forge/issues/3738
executableName: "miller",
appCategoryType: 'public.app-category.developer-tools',
name: "Miller",
},
rebuildConfig: {},
makers: [
new MakerSquirrel({}),
new MakerZIP({}),
new MakerRpm({ options: { icon: "res/icon.png" } }),
new MakerDeb({ options: { icon: "res/icon.png" } }),
new MakerRpm({ options: { icon: "res/icon.png", categories: ["Development"] } }),
new MakerDeb({ options: { icon: "res/icon.png", categories: ["Development"] } }),
],
plugins: [
new VitePlugin({

View File

@ -7,7 +7,7 @@
<link href="/src/app/index.css" rel="stylesheet" />
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
</head>
<body>
<body class="dark:scheme-dark dark:bg-neutral-800 dark:text-gray-100">
<script type="module" src="/src/app/renderer.ts"></script>
</body>
</html>

149
package-lock.json generated
View File

@ -1,19 +1,21 @@
{
"name": "miller",
"version": "0.1.0",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "miller",
"version": "0.1.0",
"license": "MIT",
"version": "0.2.4",
"license": "GPL-3.0-or-later",
"dependencies": {
"chokidar": "^5.0.0",
"electron-squirrel-startup": "^1.0.1",
"node-pty": "^1.0.0"
"node-pty": "^1.1.0-beta39"
},
"devDependencies": {
"@codemirror/language-data": "^6.5.2",
"@codemirror/lsp-client": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
@ -28,13 +30,14 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.17",
"@types/chokidar": "^1.7.5",
"@types/electron-squirrel-startup": "^1.0.2",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"codemirror": "^6.0.2",
"electron": "39.1.1",
"electron": "39.2.4",
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
"globals": "^16.5.0",
@ -48,9 +51,9 @@
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.18.6",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -440,6 +443,23 @@
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/lsp-client": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lsp-client/-/lsp-client-6.2.1.tgz",
"integrity": "sha512-fjEkEc+H0kG60thaybj5+UpSnt49yAaTzOLSYZC2wlhwNAtDsWO2uZnE2AXiRGQxBVDQBvVj01MsX3F/0Vivjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/language": "^6.11.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.0",
"@lezer/highlight": "^1.2.1",
"marked": "^15.0.12",
"vscode-languageserver-protocol": "^3.17.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
@ -3342,6 +3362,17 @@
"@types/responselike": "^1.0.0"
}
},
"node_modules/@types/chokidar": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@types/chokidar/-/chokidar-1.7.5.tgz",
"integrity": "sha512-PDkSRY7KltW3M60hSBlerxI8SFPXsO3AL/aRVsO4Kh9IHRW74Ih75gUuTd/aE4LSSFqypb10UIX3QzOJwBQMGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/events": "*",
"@types/node": "*"
}
},
"node_modules/@types/electron-squirrel-startup": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/electron-squirrel-startup/-/electron-squirrel-startup-1.0.2.tgz",
@ -3378,6 +3409,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/events": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/fs-extra": {
"version": "9.0.13",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
@ -4684,6 +4722,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
@ -5200,9 +5253,9 @@
"license": "MIT"
},
"node_modules/electron": {
"version": "39.1.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-39.1.1.tgz",
"integrity": "sha512-VuFEI1yQ7BH3RYI5VZtwFlzGp4rpPRd5oEc26ZQIItVLcLTbXt4/O7o4hs+1fyg9Q3NvGAifgX5Vp5EBOIFpAg==",
"version": "39.2.4",
"resolved": "https://registry.npmjs.org/electron/-/electron-39.2.4.tgz",
"integrity": "sha512-KxPtwpFceQKSxRtUY39piHLYhJMMyHfOhc70e6zRnKGrbRdK6hzEqssth8IGjlKOdkeT4KCvIEngnNraYk39+g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -8633,6 +8686,19 @@
"node": ">=6"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@ -8896,12 +8962,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/nan": {
"version": "2.23.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz",
"integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -8965,6 +9025,12 @@
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-api-version": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
@ -8997,13 +9063,13 @@
}
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"version": "1.1.0-beta9",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta9.tgz",
"integrity": "sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"nan": "^2.17.0"
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-releases": {
@ -9879,6 +9945,19 @@
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rechoir": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
@ -11632,6 +11711,34 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",

View File

@ -1,8 +1,8 @@
{
"name": "miller",
"productName": "miller",
"version": "0.1.0",
"description": "My Electron application description",
"version": "0.2.4",
"description": "Column-based code editor",
"main": ".vite/build/main.js",
"scripts": {
"start": "electron-forge start",
@ -17,10 +17,11 @@
"name": "Quinten Kock",
"email": "quinten@quinten.space"
},
"license": "MIT",
"license": "GPL-3.0-or-later",
"devDependencies": {
"@codemirror/language-data": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/lsp-client": "^6.2.0",
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-rpm": "^7.10.2",
@ -34,13 +35,14 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.17",
"@types/chokidar": "^1.7.5",
"@types/electron-squirrel-startup": "^1.0.2",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"codemirror": "^6.0.2",
"electron": "39.1.1",
"electron": "39.2.4",
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
"globals": "^16.5.0",
@ -53,7 +55,8 @@
"vite": "^7.2.2"
},
"dependencies": {
"chokidar": "^5.0.0",
"electron-squirrel-startup": "^1.0.1",
"node-pty": "^1.0.0"
"node-pty": "^1.1.0-beta39"
}
}

79
src/app/displayable.ts Normal file
View File

@ -0,0 +1,79 @@
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;
}

View File

@ -1,4 +1,11 @@
import { Transaction, Compartment, Extension } from "@codemirror/state";
import {
Transaction,
Compartment,
Extension,
StateEffect,
StateField,
EditorState,
} from "@codemirror/state";
import {
EditorView,
keymap,
@ -10,8 +17,9 @@ import {
dropCursor,
rectangularSelection,
crosshairCursor,
showPanel,
} from "@codemirror/view";
import { defaultKeymap, undo, redo } from "@codemirror/commands";
import { defaultKeymap, undo, redo, indentWithTab } from "@codemirror/commands";
import { oneDark } from "@codemirror/theme-one-dark";
import {
LanguageDescription,
@ -19,13 +27,23 @@ import {
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 { Displayable } from "./displayable";
import { createLspExtension } from "./lsp";
import { OpenFile } from "./filestate";
import { Displayable } from "./editorgrid";
import {
findReferencesKeymap,
formatKeymap,
renameKeymap,
} from "@codemirror/lsp-client";
import { jumpToDefinitionKeymap } from "./lsp/definition";
const fixedHeightEditor = EditorView.theme({
"&": {
@ -33,21 +51,44 @@ const fixedHeightEditor = EditorView.theme({
minHeight: "1em",
resize: "horizontal",
overflow: "auto",
width: "600px",
width: "768px",
minWidth: "8em",
flex: "none",
fontSize: "16px",
scrollMargin: "100px",
},
".cm-scroller": { overflow: "auto scroll" },
});
export class Editor implements Displayable {
const FileStatusEffect = StateEffect.define<string | null>();
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;
file: OpenFile;
deleteFn?: () => void;
private wordWrapCompartment = new Compartment();
private languageCompartment = new Compartment();
private lspCompartment = new Compartment();
dispatch(tr: Transaction, inhibitSync = false) {
this.view.update([tr]);
@ -57,11 +98,19 @@ export class Editor implements Displayable {
}
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) },
{
@ -71,7 +120,6 @@ export class Editor implements Displayable {
return true;
},
},
{ key: "Mod-w", run: () => this.close() },
{
key: "Alt-z",
run: () => {
@ -82,8 +130,6 @@ export class Editor implements Displayable {
return true;
},
},
{ key: "Alt--", run: () => this.changeWidth(-100) },
{ key: "Alt-=", run: () => this.changeWidth(100) },
]);
this.view = new EditorView({
doc: file.rootState.val.doc,
@ -92,24 +138,28 @@ export class Editor implements Displayable {
oneDark,
fixedHeightEditor,
kmap,
FileStatusField,
this.wordWrapCompartment.of(EditorView.lineWrapping),
this.languageCompartment.of([]),
this.lspCompartment.of([]),
lineNumbers(),
highlightSpecialChars(),
foldGutter(),
lintGutter(),
drawSelection(),
dropCursor(),
// allowMultipleSelections,
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
// closeBrackets,
// autocompletion,
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightActiveLineGutter(),
highlightSelectionMatches(),
// lintKeymap,
indentUnit.of(" "),
],
});
@ -117,11 +167,33 @@ export class Editor implements Displayable {
LanguageDescription.matchFilename(languages, file.filePath.val)
?.load()
.then((Lang) => {
// const eff = StateEffect.appendConfig.of(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);
});
}
get dom() {
@ -129,7 +201,7 @@ export class Editor implements Displayable {
}
focus() {
this.view.dom.scrollIntoView();
this.view.dom.scrollIntoView({ behavior: "smooth" });
this.view.focus();
}
@ -137,12 +209,6 @@ export class Editor implements Displayable {
return this.file.filePath.val + (this.file.isDirty() ? "*" : "");
}
changeWidth(increment: number) {
const w = parseInt(window.getComputedStyle(this.view.dom).width, 10);
this.view.dom.style.width = w + increment + "px";
return true;
}
close() {
if (this.deleteFn) {
this.file.removeEditor(this, this.deleteFn);
@ -157,8 +223,4 @@ export class Editor implements Displayable {
effects: compartment.reconfigure(on ? [] : extension),
});
}
setDeleteFunction(fn: () => void) {
this.deleteFn = fn;
}
}

View File

@ -6,13 +6,7 @@ import { OpenFile } from "./filestate";
import * as u from "./utils";
import { Editor } from "./editor";
import { Terminal } from "./terminal";
export interface Displayable {
setDeleteFunction(del: () => void): void;
title(): string;
close(): void;
dom: HTMLElement;
}
import { Displayable } from "./displayable";
const EditorWrapper = (
editor: State<Displayable>,
@ -21,15 +15,57 @@ const EditorWrapper = (
) => {
// Set the delete function on the editor when it's created
van.derive(() => {
if (editor.val) {
editor.val.setDeleteFunction(del);
}
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" },
{ class: "flex flex-col group" },
v.div(
{ class: "flex" },
{
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", "❌"),
),
@ -37,30 +73,35 @@ const EditorWrapper = (
);
};
const editors = vanX.reactive([[]]);
// give type to editors
const editors: Displayable[][] = vanX.reactive([[]]);
const currentTab = van.state(0);
export function addEditor(file: OpenFile) {
export function addEditor(file: OpenFile): Editor {
const editor = file.createEditor();
editors[currentTab.val].push(vanX.noreactive(editor));
editor.focus();
return editor;
}
export function addTab(file?: OpenFile) {
editors.push(file ? [file] : []);
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" : ""}`,
`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),
@ -88,8 +129,14 @@ export const EditorTabs = v.div(
vanX.list(TabBar, editors, TabHeader);
vanX.list(EditorTabs, editors, EditorGrid);
document.addEventListener("keyup", (e) => {
function shortcutHandler(e: KeyboardEvent) {
if (e.key === "t" && e.altKey) {
addTerminal();
if (e.type === "keydown") {
addTerminal();
}
e.preventDefault();
}
});
}
document.addEventListener("keyup", shortcutHandler, { capture: true });
document.addEventListener("keydown", shortcutHandler, { capture: true });

View File

@ -5,18 +5,32 @@ import {
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";
const openFiles: { [path: string]: OpenFile } = {};
// export const openFiles: { [path: string]: OpenFile } = {};
export const openFiles: Map<string, OpenFile> = new Map();
export class OpenFile {
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>;
lastSaved: State<Text>;
expectedDiskContent: State<string | null>;
knownDiskContent: State<string | null>;
diskDiscrepancyMessage: State<string | null>;
constructor(cfg: EditorStateConfig) {
this.filePath = van.state(null);
@ -27,50 +41,75 @@ export class OpenFile {
}).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[filePath]) {
return openFiles[filePath];
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) {
delete openFiles[this.filePath.val];
if (this.filePath.val) {
openFiles.delete(this.filePath.val);
}
this.filePath.val = path;
openFiles[path] = this;
openFiles.set(path, this);
// TODO: what if openFiles[path] already exists?
}
async saveFile() {
if (this.filePath.val) {
await window.electronAPI.saveFile(
this.rootState.val.doc.toString(),
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 { path } = await window.electronAPI.saveFile(
this.rootState.val.doc.toString(),
filePath,
);
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;
}
@ -89,10 +128,11 @@ export class OpenFile {
// 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) {
delete openFiles[this.filePath.val];
openFiles.delete(this.filePath.val);
}
callback();
@ -103,6 +143,8 @@ export class OpenFile {
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,
@ -123,6 +165,11 @@ export class OpenFile {
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));
@ -148,4 +195,48 @@ export class OpenFile {
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),
);
}
}
}

View File

@ -8,12 +8,65 @@ import * as u from "./utils";
const folderTreeState = van.state<FolderTree | null>(null);
van.derive(() => {
if (folderTreeState.val) {
document.title = folderTreeState.val.path + " - Miller code editor";
}
});
async function openFolder() {
const folderTree = await window.electronAPI.openFolder().catch(alert);
if (!folderTree) return;
folderTreeState.val = folderTree;
}
// Refresh the current folder tree from main (re-open)
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) {
return v.div(
@ -30,7 +83,7 @@ export const FolderTreeView = () => {
{ class: "font-bold flex-1" },
folderTreeState.val?.name ?? "No folder",
),
u.InlineButton(openFolder, "Refresh current folder", "⟳"),
u.InlineButton(refreshFolder, "Refresh current folder", "⟳"),
u.InlineButton(openFolder, "Open another folder", "📁"),
),
folderTreeState.val.children?.map(FsItemView) || [],
@ -43,7 +96,7 @@ const FsItemView = (tree: FolderTree): HTMLElement => {
if (tree.type === "file")
return v.p(
{
class: "cursor-pointer hover:bg-gray-100",
class: "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700",
onclick: async () =>
addEditor(await OpenFile.openFile(tree.path)),
},
@ -53,20 +106,22 @@ const FsItemView = (tree: FolderTree): HTMLElement => {
const isOpen = van.state(false);
const children = () =>
isOpen.val
? v.ul({}, tree.children?.map(FsItemView))
? v.ul({ class: "pl-4" }, tree.children?.map(FsItemView))
: v.div({ ariaBusy: true });
const folder = v.details(
{
class: "flex-auto inline",
ontoggle: () => (isOpen.val = folder.open),
},
v.summary(tree.name),
v.summary(
{
class: "cursor-pointer flex hover:bg-gray-100 dark:hover:bg-gray-700",
},
v.span(() => (isOpen.val ? "📂" : "📁")),
tree.name,
),
children,
);
return v.div(
{ class: "cursor-pointer flex hover:bg-gray-100" },
v.span(() => (isOpen.val ? "📂" : "📁")),
folder,
);
return folder;
};

329
src/app/lsp.ts Normal file
View File

@ -0,0 +1,329 @@
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 [];
}
}

88
src/app/lsp/codeaction.ts Normal file
View File

@ -0,0 +1,88 @@
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,
}
}

183
src/app/lsp/completion.ts Normal file
View File

@ -0,0 +1,183 @@
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
}

81
src/app/lsp/definition.ts Normal file
View File

@ -0,0 +1,81 @@
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 },
]

152
src/app/lsp/diagnostics.ts Normal file
View File

@ -0,0 +1,152 @@
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],
};
}

View File

@ -1,13 +1,12 @@
import { Displayable } from "./editorgrid";
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 implements Displayable {
export class Terminal extends Displayable {
term: xterm.Terminal;
currentTitle: State<string> = van.state("Terminal");
del: () => void;
dom: HTMLElement;
private terminalId: string | null = null;
private fitAddon: FitAddon;
@ -15,15 +14,12 @@ export class Terminal implements Displayable {
private unsubTerminalData?: () => void;
private unsubTerminalExit?: () => void;
setDeleteFunction(del: () => void): void {
this.del = del;
}
title(): string {
return this.currentTitle.val;
}
constructor() {
super();
this.term = new xterm.Terminal({
// cursorBlink: true,
// fontSize: 14,
@ -33,7 +29,10 @@ export class Terminal implements Displayable {
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
this.dom = v.div({ class: "h-full w-lg resize-x overflow-x-hidden" });
this.dom = v.div({
class: "h-full w-2xl resize-x overflow-x-hidden scroll-m-[100px]",
});
const loaded = van.state(false);
van.derive(() => {
@ -61,7 +60,7 @@ export class Terminal implements Displayable {
this.terminalId,
(exitCode) => {
this.term.writeln(
`\r\n[Process exited with code ${exitCode}]\n"Press any key to close..."`,
`\r\n[Process exited with code ${exitCode}]\r\nPress any key to close...`,
);
this.term.onData(() => this.close());
@ -108,6 +107,7 @@ export class Terminal implements Displayable {
}
focus() {
this.dom.scrollIntoView({ behavior: "smooth" });
this.term.focus();
}
@ -121,6 +121,7 @@ export class Terminal implements Displayable {
if (this.unsubTerminalData) this.unsubTerminalData();
if (this.unsubTerminalExit) this.unsubTerminalExit();
this.term.dispose();
this.del();
if (this.deleteFn) this.deleteFn();
return true;
}
}

View File

@ -2,7 +2,7 @@ import van from "vanjs-core";
const v = van.tags;
export const Button = (onclick: () => void, text: string) =>
v.button({ class: "bg-green-500 p-2", onclick }, text);
v.button({ class: "bg-green-500 dark:bg-green-700 p-2", onclick }, text);
export const InlineButton = (
onclick: () => void,

View File

@ -6,6 +6,8 @@ import fs from "fs";
const fsp = fs.promises;
import path from "path";
import * as chokidar from "chokidar";
type FolderTree = {
name: string;
path: string;
@ -15,6 +17,33 @@ type 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>();
@ -55,6 +84,10 @@ export async function handleOpenFolder(
if (!result.canceled && result.filePaths.length > 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 {
name: path.basename(folderPath),
path: folderPath,
@ -274,6 +307,17 @@ 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);

203
src/main/langserver.ts Normal file
View File

@ -0,0 +1,203 @@
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 };
},
);
}

View File

@ -4,13 +4,16 @@ import {
handleReadFile,
handleSaveFile,
// handleCreateFile,
getCurrentWorkspace,
getOpenedFiles,
showConfirmDialog,
getWorkspaceTree,
getCurrentWorkspace,
} from "./fileOperations";
import { terminalManager } from "./pty";
import path from "node:path";
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.
@ -18,15 +21,16 @@ if (started) {
app.quit();
}
app.setName("miller");
setMenu();
const createWindow = () => {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1152,
width: 1280,
height: 720,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
nodeIntegration: true,
contextIsolation: false,
},
icon: "./resources/icon.png",
});
@ -85,6 +89,11 @@ app.whenReady().then(() => {
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[]) => {
@ -98,6 +107,12 @@ app.whenReady().then(() => {
},
);
// Terminal handlers
// LSP server manager moved to src/main/langserver.ts
// It is initialized below via setupLangServer().
setupLangServer();
// Terminal handlers
ipcMain.handle(
"terminal:create",

114
src/main/menu.ts Normal file
View File

@ -0,0 +1,114 @@
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);
}

View File

@ -1,4 +1,5 @@
import * as pty from "node-pty";
import { getCurrentWorkspaceRoot } from "./fileOperations";
export interface TerminalInstance {
ptyProcess: pty.IPty;
@ -24,7 +25,7 @@ export class TerminalManager {
name: "xterm-color",
cols: 80,
rows: 24,
cwd: process.cwd(),
cwd: getCurrentWorkspaceRoot() || process.cwd(),
env: process.env,
});

View File

@ -3,11 +3,6 @@
import { contextBridge, ipcRenderer } from "electron";
import type { FolderTree } from "./types/global";
import fs from "fs";
import * as pty from "node-pty";
window.fs = fs;
window.pty = pty;
// Centralized routing for terminal events: keep a single ipcRenderer listener
// and forward events to subscribed callbacks. Each `onTerminal*` returns an
@ -28,7 +23,7 @@ ipcRenderer.on("terminal:exit", (_ev, id: string, exitCode: number) => {
else console.warn(`No exit callback for terminal ${id}`);
});
window.electronAPI = {
contextBridge.exposeInMainWorld("electronAPI", {
openFolder: () =>
ipcRenderer.invoke("dialog:openFolder") as Promise<FolderTree | null>,
@ -60,6 +55,10 @@ window.electronAPI = {
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",
@ -96,4 +95,55 @@ window.electronAPI = {
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);
}
});
},
});

23
src/types/global.d.ts vendored
View File

@ -27,6 +27,8 @@ declare global {
// Workspace info
getCurrentWorkspace: () => Promise<{ root: string | null }>;
getOpenedFiles: () => Promise<string[]>;
// Get workspace tree without dialogs
getWorkspaceTree: () => Promise<FolderTree | null>;
// Dialog operations
showConfirmDialog: (
@ -55,8 +57,27 @@ declare global {
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>;
};
fs: typeof import("fs");
}
}