├── go.mod
├── go.sum
├── server
├── static
│ ├── utility.js
│ ├── index.html
│ ├── styles.css
│ ├── app.js
│ ├── neovim.svg
│ ├── neovim-inactive.svg
│ ├── client.js
│ └── renderer.js
└── server.go
├── main.go
├── .gitignore
├── LICENSE
├── .github
└── workflows
│ └── go.yml
└── README.md
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/Kraust/nvim-server
2 |
3 | go 1.24.6
4 |
5 | require (
6 | github.com/gorilla/websocket v1.5.3 // indirect
7 | github.com/neovim/go-client v1.2.1 // indirect
8 | )
9 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
2 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
3 | github.com/neovim/go-client v1.2.1 h1:kl3PgYgbnBfvaIoGYi3ojyXH0ouY6dJY/rYUCssZKqI=
4 | github.com/neovim/go-client v1.2.1/go.mod h1:EeqCP3z1vJd70JTaH/KXz9RMZ/nIgEFveX83hYnh/7c=
5 |
--------------------------------------------------------------------------------
/server/static/utility.js:
--------------------------------------------------------------------------------
1 | function getUrlParameter(name) {
2 | const urlParams = new URLSearchParams(window.location.search);
3 | return urlParams.get(name);
4 | }
5 |
6 | function isValidServerAddress(address) {
7 | const pattern = /^[a-zA-Z0-9.-]+:\d+$/;
8 | return pattern.test(address);
9 | }
10 |
11 | if (typeof module !== "undefined" && module.exports) {
12 | module.exports = { getUrlParameter, isValidServerAddress };
13 | }
14 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "github.com/Kraust/nvim-server/server"
7 | "log"
8 | "os"
9 | )
10 |
11 | var (
12 | version = "dev"
13 |
14 | f_address = flag.String("address", "127.0.0.1:9998", "Specifies the address to bind the server to.")
15 | f_version = flag.Bool("version", false, "Show version information and exit.")
16 | )
17 |
18 | func main() {
19 | flag.Parse()
20 |
21 | if *f_version {
22 | fmt.Printf("nvim-server %s\n", version)
23 | os.Exit(0)
24 | }
25 |
26 | err := server.Serve(*f_address)
27 | if err != nil {
28 | log.Fatalf("%s", err)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Code coverage profiles and other test artifacts
15 | *.out
16 | coverage.*
17 | *.coverprofile
18 | profile.cov
19 |
20 | # Dependency directories (remove the comment below to include it)
21 | # vendor/
22 |
23 | # Go workspace file
24 | go.work
25 | go.work.sum
26 |
27 | # env file
28 | .env
29 |
30 | # Editor/IDE
31 | # .idea/
32 | # .vscode/
33 |
34 | # FIXME:
35 | nvim-server
36 |
37 |
--------------------------------------------------------------------------------
/server/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Neovim Server
6 |
7 |
8 |
9 |
10 |
11 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Kraust
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/server/static/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: Arial, sans-serif;
5 | background: #1a1a1a;
6 | color: #fff;
7 | height: 100vh;
8 | display: flex;
9 | flex-direction: column;
10 | overflow: hidden;
11 | }
12 |
13 | #connection-form {
14 | flex-shrink: 0;
15 | margin: 20px;
16 | padding: 10px;
17 | background: #2a2a2a;
18 | border-radius: 5px;
19 | transition: opacity 0.3s ease-out;
20 | }
21 |
22 | #terminal {
23 | flex: 1;
24 | background: #000;
25 | display: none;
26 | cursor: text;
27 | margin: 0;
28 | padding: 0;
29 | border: none;
30 | outline: none;
31 | transition: all 0.3s ease-out;
32 | transform: translateZ(0);
33 | will-change: transform;
34 | backface-visibility: hidden;
35 | perspective: 1000;
36 | }
37 |
38 | #terminal.connected {
39 | display: block;
40 | position: fixed;
41 | top: 0;
42 | left: 0;
43 | width: 100vw;
44 | height: 100vh;
45 | margin: 0;
46 | padding: 0;
47 | border: none;
48 | }
49 |
50 | #terminal:focus {
51 | outline: none;
52 | }
53 |
54 | #connection-form input {
55 | padding: 8px;
56 | margin-right: 10px;
57 | border: 1px solid #555;
58 | background: #333;
59 | color: #fff;
60 | border-radius: 3px;
61 | }
62 |
63 | #connection-form button {
64 | padding: 8px 16px;
65 | background: #007acc;
66 | color: white;
67 | border: none;
68 | border-radius: 3px;
69 | cursor: pointer;
70 | }
71 |
72 | #connection-form button:hover {
73 | background: #005a9e;
74 | }
75 |
76 | #status {
77 | margin-top: 10px;
78 | padding: 5px;
79 | font-size: 14px;
80 | }
81 |
--------------------------------------------------------------------------------
/server/static/app.js:
--------------------------------------------------------------------------------
1 | // Global client instance
2 | const client = new NeovimClient();
3 | globalThis.client = client;
4 |
5 | // Setup keyboard handlers
6 | client.setupKeyboardHandlers();
7 |
8 | // Window load handler
9 | globalThis.addEventListener("load", () => {
10 | client.connect();
11 |
12 | const serverAddress = getUrlParameter("server");
13 | if (serverAddress && isValidServerAddress(serverAddress)) {
14 | const connectionForm = document.getElementById("connection-form");
15 | if (connectionForm) {
16 | connectionForm.style.opacity = "0.5";
17 | }
18 |
19 | const addressInput = document.getElementById("nvim-address");
20 | if (addressInput) {
21 | addressInput.value = decodeURIComponent(serverAddress);
22 | }
23 |
24 | setTimeout(() => {
25 | client.updateStatus("Auto-connecting to " + serverAddress + "...");
26 | client.updateTitle(serverAddress + " (connecting...)");
27 | client.updateFavicon("default");
28 | client.connectToNeovim(decodeURIComponent(serverAddress));
29 | }, 500);
30 | }
31 | });
32 |
33 | // Terminal event handlers
34 | const terminal = document.getElementById("terminal");
35 |
36 | terminal.addEventListener("keyup", (_event) => {
37 | if (!client.connected) return;
38 | });
39 |
40 | terminal.addEventListener("paste", (event) => {
41 | if (!client.connected) return;
42 |
43 | event.preventDefault();
44 | const text = event.clipboardData.getData("text");
45 | client.sendInput(text);
46 | });
47 |
48 | // Global connection function
49 | globalThis.connectToNeovim = function () {
50 | const addressInput = document.getElementById("nvim-address");
51 | if (!addressInput) {
52 | console.error("Address input not found");
53 | return;
54 | }
55 |
56 | const address = addressInput.value;
57 |
58 | if (address.trim()) {
59 | client.connectToNeovim(address);
60 | } else {
61 | client.updateStatus("Please enter a valid address");
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/server/static/neovim.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/server/static/neovim-inactive.svg:
--------------------------------------------------------------------------------
1 |
2 |
28 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Go
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | tags: ["v*"]
7 | pull_request:
8 | branches: ["main"]
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | goos: [linux, windows, darwin]
16 | goarch: [amd64, arm64]
17 | exclude:
18 | - goos: windows
19 | goarch: arm64
20 | steps:
21 | - uses: actions/checkout@v4
22 | with:
23 | fetch-depth: 0
24 |
25 | - name: Set up Go
26 | uses: actions/setup-go@v4
27 | with:
28 | go-version: "1.24.6"
29 |
30 | - name: Get version info
31 | id: version
32 | run: |
33 | if [[ $GITHUB_REF == refs/tags/* ]]; then
34 | VERSION=${GITHUB_REF#refs/tags/}
35 | else
36 | VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
37 | fi
38 | COMMIT=$(git rev-parse --short HEAD)
39 | DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
40 | echo "version=$VERSION" >> $GITHUB_OUTPUT
41 |
42 | - name: Build
43 | env:
44 | GOOS: ${{ matrix.goos }}
45 | GOARCH: ${{ matrix.goarch }}
46 | run: |
47 | mkdir -p dist
48 | LDFLAGS="-X main.version=${{ steps.version.outputs.version }}"
49 | if [ "$GOOS" = "windows" ]; then
50 | go build -ldflags "$LDFLAGS" -o dist/nvim-server-${{ matrix.goos }}-${{ matrix.goarch }}.exe
51 | else
52 | go build -ldflags "$LDFLAGS" -o dist/nvim-server-${{ matrix.goos }}-${{ matrix.goarch }}
53 | fi
54 |
55 | - name: Upload artifacts
56 | uses: actions/upload-artifact@v4
57 | with:
58 | name: nvim-server-${{ matrix.goos }}-${{ matrix.goarch }}
59 | path: dist/
60 | test:
61 | runs-on: ubuntu-latest
62 | steps:
63 | - uses: actions/checkout@v4
64 | - name: Set up Go
65 | uses: actions/setup-go@v4
66 | with:
67 | go-version: "1.24.6"
68 | - name: Test
69 | run: go test -v ./...
70 |
71 | release:
72 | if: startsWith(github.ref, 'refs/tags/v')
73 | needs: [build, test]
74 | runs-on: ubuntu-latest
75 | permissions:
76 | contents: write
77 | steps:
78 | - uses: actions/checkout@v4
79 | - name: Download all artifacts
80 | uses: actions/download-artifact@v4
81 | with:
82 | path: artifacts
83 | - name: Create Release
84 | uses: ncipollo/release-action@v1
85 | with:
86 | generateReleaseNotes: true
87 | allowUpdates: true
88 | artifacts: "artifacts/*/nvim-server-*"
89 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Neovim in the Browser
2 |
3 |
4 |
5 | `nvim-server` is a web frontend for [Neovim](https://neovim.io/) designed around
6 | allowing the user to run Neovim anywhere you have a browser.
7 |
8 | Note this project was vibe coded over a two day period, and I'm at the point in
9 | which I believe I have a minimal viable product. Next steps include addressing
10 | roadmap items and trying to refactor / understand parts of the code I had the
11 | AI generate. Right now I'd consider nvim-server an MVP based on my personal
12 | requirements.
13 |
14 | ## Features
15 |
16 | - One server can connect to multiple clients.
17 | - Full clipboard integration using a custom clipboard provider.
18 | - GPU acceleration.
19 |
20 | ## Usage
21 |
22 | First spawn the server:
23 |
24 | ```
25 | $ ./nvim-server --address 0.0.0.0:9998
26 | ```
27 |
28 | Then you can go to `http://localhost:9998` and enter the location of a remote
29 | neovim instance. You can optionally pass in the server address as a query
30 | string (e.g. `http://localhost:9998/?server=localhost:9000`) to automatically
31 | connect to your Neovim instance.
32 |
33 | Optionally, you can create a systemd unit to automate this entire process:
34 |
35 | ```
36 | [Unit]
37 | Description=nvim-server
38 |
39 | [Service]
40 | ExecStart=nvim-server --address 0.0.0.0:9998
41 | Restart=always
42 |
43 | [Install]
44 | WantedBy=default.target
45 | ```
46 |
47 | Note that if your nvim-server and nvim are on different LANs you may want to
48 | use a secure tunnel to encrypt your neovim RPC traffic.
49 |
50 | ## Clipboard Support
51 |
52 | Clipboard Support requires the user to have nvim-server running behind HTTPS
53 | as browsers block clipboard sharing for HTTP connections.
54 |
55 | ## Project Background
56 |
57 | Before starting this project I wrote a couple of blog posts about Neovim being
58 | a terminal emulator / multiplexer replacement. I may write future posts in the
59 | future elaborating on why Neovim in the browser was my eventual conclusion for
60 | creating an optimal development workflow.
61 |
62 | - [Remote Neovim for Dummies](https://kraust.github.io/posts/remote-neovim-for-dummies/)
63 | - [Neovim is a Multiplexer](https://kraust.github.io/posts/neovim-is-a-multiplexer/)
64 |
65 | ## Roadmap
66 |
67 | - Better font rendering support.
68 |
69 | ## Similar Projects
70 |
71 | - [Code Server](https://github.com/coder/code-server) - VSCode in the Browser
72 | - [Glowing Bear](https://github.com/glowing-bear/glowing-bear) - WeeChat in the Browser
73 | - [Neovide](https://github.com/neovide/neovide) - An amazing Neovim GUI that I've been using since 2020.
74 |
75 |
--------------------------------------------------------------------------------
/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "embed"
5 | "fmt"
6 | "github.com/gorilla/websocket"
7 | "github.com/neovim/go-client/nvim"
8 | "io/fs"
9 | "log"
10 | "net/http"
11 | "strings"
12 | "sync"
13 | )
14 |
15 | //go:embed static/*
16 | var staticFiles embed.FS
17 |
18 | type ClientSession struct {
19 | nvim *nvim.Nvim
20 | conn *websocket.Conn
21 | address string
22 | active bool
23 | uiAttached bool
24 | }
25 |
26 | type Server struct {
27 | upgrader websocket.Upgrader
28 | clients map[*websocket.Conn]*ClientSession
29 | mu sync.RWMutex
30 | }
31 |
32 | func Serve(address string) error {
33 | ctx := &Server{
34 | upgrader: websocket.Upgrader{
35 | CheckOrigin: func(r *http.Request) bool { return true },
36 | },
37 | clients: make(map[*websocket.Conn]*ClientSession),
38 | }
39 |
40 | staticFS, _ := fs.Sub(staticFiles, "static")
41 | http.Handle("/", http.FileServer(http.FS(staticFS)))
42 |
43 | http.HandleFunc("/ws", ctx.handleWebSocket)
44 |
45 | log.Printf("Server starting on %s", address)
46 |
47 | err := http.ListenAndServe(address, nil)
48 | if err != nil {
49 | return err
50 | }
51 |
52 | return nil
53 | }
54 |
55 | func (ctx *Server) listenToNeovimEvents(session *ClientSession) error {
56 | session.nvim.RegisterHandler("redraw", func(updates ...[]any) {
57 | if !session.active {
58 | return
59 | }
60 | for _, update := range updates {
61 | message := map[string]any{
62 | "type": "redraw",
63 | "data": update,
64 | }
65 | ctx.sendToClient(session, message)
66 | }
67 | })
68 |
69 | if err := session.nvim.Subscribe("redraw"); err != nil {
70 | return fmt.Errorf("failed to subscribe to redraw events: %w", err)
71 | }
72 |
73 | err := session.nvim.Serve()
74 |
75 | log.Printf("Neovim session closed for client")
76 |
77 | session.active = false
78 | session.uiAttached = false // Reset UI state
79 | ctx.sendToClient(session, map[string]any{
80 | "type": "session_closed",
81 | "data": "Neovim session has been closed",
82 | })
83 |
84 | return err
85 | }
86 |
87 | func (ctx *Server) sendToClient(session *ClientSession, message map[string]any) {
88 | if !session.active && message["type"] != "session_closed" {
89 | return
90 | }
91 |
92 | err := session.conn.WriteJSON(message)
93 | if err != nil {
94 | log.Printf("Write error to client: %v", err)
95 | session.active = false
96 | session.conn.Close()
97 | }
98 | }
99 |
100 | func (ctx *Server) handleClientMessage(session *ClientSession, msg map[string]any) {
101 | switch msg["type"] {
102 | case "connect":
103 | address, ok := msg["address"].(string)
104 | if !ok {
105 | ctx.sendToClient(session, map[string]any{
106 | "type": "error",
107 | "data": "Invalid server address",
108 | })
109 | return
110 | }
111 |
112 | if err := ctx.connectSessionToNeovim(session, address); err != nil {
113 | log.Printf("Failed to connect client to Neovim at %s: %v", address, err)
114 | ctx.sendToClient(session, map[string]any{
115 | "type": "error",
116 | "data": fmt.Sprintf("Failed to connect to Neovim: %v", err),
117 | })
118 | return
119 | }
120 |
121 | ctx.sendToClient(session, map[string]any{
122 | "type": "connected",
123 | "data": "Successfully connected to Neovim",
124 | })
125 | case "clipboard_content":
126 | if !session.active || session.nvim == nil {
127 | return
128 | }
129 |
130 | err := session.nvim.SetVar("nvim_server_clipboard", msg["data"])
131 | if err != nil {
132 | log.Printf("Failed to set clipboard variable: %v", err)
133 | }
134 | default:
135 | if !session.active || session.nvim == nil {
136 | ctx.sendToClient(session, map[string]any{
137 | "type": "error",
138 | "data": "Not connected to Neovim",
139 | })
140 | return
141 | }
142 |
143 | ctx.handleNeovimCommand(session, msg)
144 | }
145 | }
146 |
147 | func (ctx *Server) handleNeovimCommand(session *ClientSession, msg map[string]any) {
148 | if !session.active || session.nvim == nil {
149 | ctx.sendToClient(session, map[string]any{
150 | "type": "error",
151 | "data": "Neovim session is no longer active",
152 | })
153 | return
154 | }
155 |
156 | switch msg["type"] {
157 | case "attach_ui":
158 | width := int(msg["width"].(float64))
159 | height := int(msg["height"].(float64))
160 | options := map[string]any{
161 | "ext_linegrid": true,
162 | "ext_multigrid": false,
163 | "rgb": true,
164 | }
165 | if err := session.nvim.AttachUI(width, height, options); err != nil {
166 | log.Printf("Error attaching UI: %v", err)
167 | session.uiAttached = false
168 | if strings.Contains(err.Error(), "session closed") {
169 | session.active = false
170 | ctx.sendToClient(session, map[string]any{
171 | "type": "session_closed",
172 | "data": "Neovim session has been closed",
173 | })
174 | }
175 | } else {
176 | session.uiAttached = true
177 | }
178 | case "input":
179 | input := msg["data"].(string)
180 | if _, err := session.nvim.Input(input); err != nil {
181 | log.Printf("Error sending input: %v", err)
182 | if strings.Contains(err.Error(), "session closed") {
183 | session.active = false
184 | ctx.sendToClient(session, map[string]any{
185 | "type": "session_closed",
186 | "data": "Neovim session has been closed",
187 | })
188 | }
189 | }
190 | case "command":
191 | cmd := msg["data"].(string)
192 |
193 | if strings.Contains(cmd, "nvim_ui_attach") {
194 | if err := session.nvim.AttachUI(80, 24, map[string]any{
195 | "ext_linegrid": true,
196 | "ext_multigrid": false,
197 | "rgb": true,
198 | }); err != nil {
199 | log.Printf("Error attaching UI: %v", err)
200 | }
201 | } else if after, ok := strings.CutPrefix(cmd, "lua "); ok {
202 | luaCode := after
203 | if err := session.nvim.ExecLua(luaCode, nil); err != nil {
204 | log.Printf("Error executing Lua: %v", err)
205 | }
206 | } else {
207 | if err := session.nvim.Command(cmd); err != nil {
208 | log.Printf("Error executing command: %v", err)
209 | }
210 | }
211 | case "resize":
212 | if !session.uiAttached {
213 | return
214 | }
215 |
216 | width := int(msg["width"].(float64))
217 | height := int(msg["height"].(float64))
218 | if err := session.nvim.TryResizeUI(width, height); err != nil {
219 | log.Printf("Error resizing UI: %v", err)
220 | if strings.Contains(err.Error(), "UI not attached") {
221 | session.uiAttached = false
222 | } else if strings.Contains(err.Error(), "session closed") {
223 | session.active = false
224 | session.uiAttached = false
225 | ctx.sendToClient(session, map[string]any{
226 | "type": "session_closed",
227 | "data": "Neovim session has been closed",
228 | })
229 | }
230 | }
231 | case "mouse":
232 | action := msg["action"].(string)
233 | button := int(msg["button"].(float64))
234 | row := int(msg["row"].(float64))
235 | col := int(msg["col"].(float64))
236 |
237 | var input string
238 | switch button {
239 | case 0:
240 | switch action {
241 | case "press":
242 | input = fmt.Sprintf("<%d,%d>", col, row)
243 | case "drag":
244 | input = fmt.Sprintf("<%d,%d>", col, row)
245 | default:
246 | input = fmt.Sprintf("<%d,%d>", col, row)
247 | }
248 | case 2:
249 | switch action {
250 | case "press":
251 | input = fmt.Sprintf("<%d,%d>", col, row)
252 | case "drag":
253 | input = fmt.Sprintf("<%d,%d>", col, row)
254 | default:
255 | input = fmt.Sprintf("<%d,%d>", col, row)
256 | }
257 | }
258 |
259 | if input != "" {
260 | if _, err := session.nvim.Input(input); err != nil {
261 | log.Printf("Error sending mouse input: %v", err)
262 | }
263 | }
264 | case "scroll":
265 | direction := msg["direction"].(string)
266 | row := int(msg["row"].(float64))
267 | col := int(msg["col"].(float64))
268 |
269 | var input string
270 | if direction == "up" {
271 | input = fmt.Sprintf("<%d,%d>", col, row)
272 | } else {
273 | input = fmt.Sprintf("<%d,%d>", col, row)
274 | }
275 |
276 | if _, err := session.nvim.Input(input); err != nil {
277 | log.Printf("Error sending scroll input: %v", err)
278 | }
279 |
280 | }
281 |
282 | }
283 |
284 | func (ctx *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
285 | conn, err := ctx.upgrader.Upgrade(w, r, nil)
286 | if err != nil {
287 | log.Printf("WebSocket upgrade error: %v", err)
288 | return
289 | }
290 | defer conn.Close()
291 |
292 | session := &ClientSession{
293 | conn: conn,
294 | active: false,
295 | }
296 |
297 | ctx.mu.Lock()
298 | ctx.clients[conn] = session
299 | ctx.mu.Unlock()
300 |
301 | defer func() {
302 | ctx.mu.Lock()
303 | if session.nvim != nil {
304 | session.nvim.Close()
305 | }
306 | delete(ctx.clients, conn)
307 | ctx.mu.Unlock()
308 | }()
309 |
310 | conn.WriteJSON(map[string]any{
311 | "type": "ready",
312 | "data": "WebSocket connected. Please provide Neovim server addresctx.",
313 | })
314 |
315 | for {
316 | var msg map[string]any
317 | err := conn.ReadJSON(&msg)
318 | if err != nil {
319 | log.Printf("Read error: %v", err)
320 | break
321 | }
322 |
323 | ctx.handleClientMessage(session, msg)
324 | }
325 | }
326 |
327 | func (ctx *Server) connectSessionToNeovim(session *ClientSession, address string) error {
328 | if session.nvim != nil {
329 | session.nvim.Close()
330 | session.nvim = nil
331 | }
332 |
333 | client, err := nvim.Dial(address)
334 | if err != nil {
335 | return fmt.Errorf("failed to dial %s: %w", address, err)
336 | }
337 |
338 | session.nvim = client
339 | session.address = address
340 | session.active = true
341 | log.Printf("Successfully connected client to neovim at %s", address)
342 |
343 | if err := ctx.setupClipboard(session); err != nil {
344 | log.Printf("Failed to setup clipboard: %v", err)
345 | }
346 |
347 | go func() {
348 | if err := ctx.listenToNeovimEvents(session); err != nil {
349 | log.Printf("Error in Neovim event listener: %v", err)
350 | }
351 | }()
352 |
353 | return nil
354 | }
355 |
356 | func (ctx *Server) setupClipboard(session *ClientSession) error {
357 | channelID := session.nvim.ChannelID()
358 |
359 | clipboardConfig := fmt.Sprintf(`
360 | vim.g.clipboard = {
361 | name = 'nvim-server',
362 | copy = {
363 | ['+'] = function(lines, regtype)
364 | local content = table.concat(lines, '\n')
365 | vim.rpcnotify(%d, 'clipboard_copy', content)
366 | return 0
367 | end,
368 | ['*'] = function(lines, regtype)
369 | local content = table.concat(lines, '\n')
370 | vim.rpcnotify(%d, 'clipboard_copy', content)
371 | return 0
372 | end,
373 | },
374 | paste = {
375 | ['+'] = function()
376 | vim.g.nvim_server_clipboard = nil
377 | vim.rpcnotify(%d, 'clipboard_paste')
378 |
379 | local timeout = 300
380 | while timeout > 0 and vim.g.nvim_server_clipboard == nil do
381 | vim.wait(10)
382 | timeout = timeout - 1
383 | end
384 |
385 | local content = vim.g.nvim_server_clipboard
386 | if content == nil or content == '' then
387 | print('Clipboard paste timeout or empty')
388 | return {''}
389 | end
390 |
391 | return vim.split(content, '\n', { plain = true })
392 | end,
393 | ['*'] = function()
394 | vim.g.nvim_server_clipboard = nil
395 | vim.rpcnotify(%d, 'clipboard_paste')
396 |
397 | local timeout = 300
398 | while timeout > 0 and vim.g.nvim_server_clipboard == nil do
399 | vim.wait(10)
400 | timeout = timeout - 1
401 | end
402 |
403 | local content = vim.g.nvim_server_clipboard
404 | if content == nil or content == '' then
405 | return {''}
406 | end
407 |
408 | return vim.split(content, '\n', { plain = true })
409 | end,
410 | }
411 | }
412 | return true
413 | `, channelID, channelID, channelID, channelID)
414 |
415 | var result bool
416 | if err := session.nvim.ExecLua(clipboardConfig, &result); err != nil {
417 | return err
418 | }
419 |
420 | reloadConfig := `
421 | vim.g.loaded_clipboard_provider = nil
422 | vim.cmd('runtime autoload/provider/clipboard.vim')
423 | vim.opt.clipboard = 'unnamedplus'
424 | return true
425 | `
426 |
427 | if err := session.nvim.ExecLua(reloadConfig, &result); err != nil {
428 | return err
429 | }
430 |
431 | // Register message handlers
432 | session.nvim.RegisterHandler("clipboard_copy", func(content string) {
433 | ctx.sendToClient(session, map[string]any{
434 | "type": "clipboard_set",
435 | "data": content,
436 | })
437 | })
438 |
439 | session.nvim.RegisterHandler("clipboard_paste", func() {
440 | ctx.sendToClient(session, map[string]any{
441 | "type": "clipboard_get",
442 | })
443 | })
444 |
445 | return nil
446 | }
447 |
--------------------------------------------------------------------------------
/server/static/client.js:
--------------------------------------------------------------------------------
1 | class NeovimClient {
2 | constructor() {
3 | this.ws = null;
4 | this.connected = false;
5 | this.renderer = null;
6 | this.clipboardEnabled = navigator.clipboard &&
7 | globalThis.isSecureContext;
8 | this.lastClipboardContent = "";
9 | this.requestClipboardPermission();
10 | }
11 |
12 | initRenderer() {
13 | const canvas = document.getElementById("terminal");
14 | if (canvas) {
15 | this.renderer = new NeovimRenderer(canvas);
16 | }
17 | }
18 |
19 | showConnectionForm() {
20 | const connectionForm = document.getElementById("connection-form");
21 | const terminal = document.getElementById("terminal");
22 |
23 | if (connectionForm) {
24 | connectionForm.style.display = "block";
25 | connectionForm.style.opacity = "1";
26 | }
27 |
28 | if (terminal) {
29 | terminal.classList.remove("connected");
30 | terminal.style.display = "none";
31 | }
32 |
33 | if (this.renderer) {
34 | this.renderer = null;
35 | }
36 |
37 | const addressInput = document.getElementById("nvim-address");
38 | if (addressInput) {
39 | addressInput.focus();
40 | }
41 | }
42 |
43 | updateTitle(serverAddress = null, status = null) {
44 | let title = "Neovim Server";
45 |
46 | if (serverAddress) {
47 | title += ` - ${serverAddress}`;
48 | }
49 |
50 | if (status) {
51 | title += ` (${status})`;
52 | }
53 |
54 | document.title = title;
55 | }
56 |
57 | updateFavicon(status = "default") {
58 | let link = document.querySelector("link[rel*='icon']");
59 | if (!link) {
60 | link = document.createElement("link");
61 | link.type = "image/svg+xml";
62 | link.rel = "shortcut icon";
63 | document.head.appendChild(link);
64 | }
65 |
66 | switch (status) {
67 | case "error": {
68 | link.href = "neovim-inactive.svg";
69 | break;
70 | }
71 | case "connecting": {
72 | link.href = "neovim-inactive.svg";
73 | break;
74 | }
75 | default: {
76 | link.href = "neovim.svg";
77 | break;
78 | }
79 | }
80 | }
81 |
82 | handleMessage(msg) {
83 | switch (msg.type) {
84 | case "ready": {
85 | this.updateStatus("Ready to connect to Neovim");
86 | this.updateTitle();
87 | this.updateFavicon("default");
88 | break;
89 | }
90 | case "connected": {
91 | this.connected = true;
92 | this.updateStatus(
93 | "Connected to Neovim successfully! Initializing UI...",
94 | );
95 |
96 | const addressInput = document.getElementById("nvim-address");
97 | if (addressInput && addressInput.value) {
98 | this.updateTitle(addressInput.value);
99 | this.updateFavicon("connected");
100 | }
101 |
102 | this.hideConnectionForm();
103 | this.initRenderer();
104 |
105 | setTimeout(() => {
106 | this.resizeTerminalToFullViewport();
107 | this.attachUI();
108 | }, 100);
109 |
110 | this.sendCommand("set mouse=a");
111 |
112 | document.getElementById("terminal").focus();
113 | break;
114 | }
115 | case "session_closed": {
116 | this.connected = false;
117 | this.updateStatus("Neovim session closed - " + msg.data);
118 | this.updateTitle();
119 | this.updateFavicon("error");
120 | this.showConnectionForm();
121 | break;
122 | }
123 | case "error": {
124 | console.error("Error:", msg.data);
125 | this.updateStatus("Error: " + msg.data);
126 | this.updateTitle();
127 | this.updateFavicon("error");
128 | break;
129 | }
130 | case "redraw": {
131 | if (this.renderer && Array.isArray(msg.data)) {
132 | this.renderer.handleRedrawEvent(msg.data);
133 | }
134 | break;
135 | }
136 | case "clipboard_set": {
137 | if (this.clipboardEnabled && msg.data) {
138 | this.syncToSystemClipboard(msg.data);
139 | }
140 | break;
141 | }
142 | case "clipboard_get": {
143 | this.sendClipboardToNeovim();
144 | break;
145 | }
146 | default: {
147 | console.log("Unknown message type:", msg.type);
148 | }
149 | }
150 | }
151 |
152 | autoConnect(address) {
153 | if (this.ws && this.ws.readyState === WebSocket.OPEN) {
154 | this.connectToNeovim(address);
155 | } else {
156 | const checkConnection = setInterval(() => {
157 | if (this.ws && this.ws.readyState === WebSocket.OPEN) {
158 | clearInterval(checkConnection);
159 | this.connectToNeovim(address);
160 | }
161 | }, 100);
162 |
163 | setTimeout(() => {
164 | clearInterval(checkConnection);
165 | this.updateStatus(
166 | "Failed to establish WebSocket connection for auto-connect",
167 | );
168 | }, 5000);
169 | }
170 | }
171 |
172 | attachUI() {
173 | if (this.connected && this.ws && this.renderer) {
174 | this.ws.send(
175 | JSON.stringify({
176 | type: "attach_ui",
177 | width: this.renderer.cols,
178 | height: this.renderer.rows,
179 | }),
180 | );
181 | this.renderer.startCursorBlink();
182 | this.updateStatus("UI attachment requested...");
183 | }
184 | }
185 |
186 | hideConnectionForm() {
187 | const connectionForm = document.getElementById("connection-form");
188 | const terminal = document.getElementById("terminal");
189 |
190 | if (connectionForm) {
191 | connectionForm.style.display = "none";
192 | }
193 |
194 | if (terminal) {
195 | terminal.classList.add("connected");
196 | terminal.style.display = "block";
197 | }
198 |
199 | this.resizeTerminalToFullViewport();
200 | }
201 |
202 | resizeTerminalToFullViewport() {
203 | const canvas = document.getElementById("terminal");
204 | if (!canvas || !this.renderer) return;
205 |
206 | const containerWidth = globalThis.innerWidth;
207 | const containerHeight = globalThis.innerHeight;
208 |
209 | const newDimensions = this.renderer.resize(
210 | containerWidth,
211 | containerHeight,
212 | );
213 | this.sendResize(newDimensions.width, newDimensions.height);
214 | }
215 |
216 | connect() {
217 | const protocol = globalThis.location.protocol === "https:"
218 | ? "wss:"
219 | : "ws:";
220 | const wsUrl = `${protocol}//${globalThis.location.host}/ws`;
221 | this.ws = new WebSocket(wsUrl);
222 |
223 | this.ws.onopen = () => {
224 | this.updateStatus("WebSocket connected");
225 | this.setupResizeHandler();
226 | this.setupMouseHandlers();
227 | };
228 |
229 | this.ws.onmessage = (event) => {
230 | const msg = JSON.parse(event.data);
231 | this.handleMessage(msg);
232 | };
233 |
234 | this.ws.onclose = () => {
235 | this.connected = false;
236 | this.updateStatus("WebSocket disconnected - Connection lost");
237 | this.updateTitle();
238 | this.updateFavicon("error");
239 | this.showConnectionForm();
240 | };
241 |
242 | this.ws.onerror = (error) => {
243 | console.error("WebSocket error:", error);
244 | this.updateStatus("WebSocket error");
245 | this.updateFavicon("error");
246 | this.showConnectionForm();
247 | };
248 | }
249 |
250 | setupMouseHandlers() {
251 | const terminal = document.getElementById("terminal");
252 | if (!terminal) return;
253 |
254 | let dragThrottle = null;
255 |
256 | terminal.addEventListener("click", () => {
257 | terminal.focus();
258 | });
259 |
260 | terminal.addEventListener("contextmenu", (event) => {
261 | event.preventDefault();
262 | event.stopPropagation();
263 | });
264 |
265 | terminal.addEventListener("focus", () => {
266 | if (this.connected && this.clipboardEnabled) {
267 | this.sendClipboardToNeovim();
268 | }
269 | });
270 |
271 | // Also sync on window focus
272 | globalThis.addEventListener("focus", () => {
273 | if (this.connected && this.clipboardEnabled) {
274 | this.sendClipboardToNeovim();
275 | }
276 | });
277 |
278 | terminal.addEventListener("mousedown", (event) => {
279 | if (!this.connected || !this.renderer) return;
280 | terminal.focus();
281 |
282 | const coords = this.getMouseCoords(event);
283 | this.sendMouseEvent("press", coords.row, coords.col, event.button);
284 | event.preventDefault();
285 | });
286 |
287 | terminal.addEventListener("mouseup", (event) => {
288 | if (!this.connected || !this.renderer) return;
289 |
290 | const coords = this.getMouseCoords(event);
291 | this.sendMouseEvent(
292 | "release",
293 | coords.row,
294 | coords.col,
295 | event.button,
296 | );
297 | event.preventDefault();
298 | });
299 |
300 | terminal.addEventListener("wheel", (event) => {
301 | if (!this.connected || !this.renderer) return;
302 |
303 | const coords = this.getMouseCoords(event);
304 | const direction = event.deltaY > 0 ? "down" : "up";
305 | this.sendScrollEvent(direction, coords.row, coords.col);
306 | event.preventDefault();
307 | });
308 |
309 | terminal.addEventListener("mousemove", (event) => {
310 | if (!this.connected || !this.renderer) return;
311 |
312 | if (event.buttons > 0) {
313 | if (!dragThrottle) {
314 | dragThrottle = setTimeout(() => {
315 | const coords = this.getMouseCoords(event);
316 | this.sendMouseEvent(
317 | "drag",
318 | coords.row,
319 | coords.col,
320 | event.button,
321 | );
322 | dragThrottle = null;
323 | }, 16);
324 | }
325 | event.preventDefault();
326 | }
327 | });
328 | }
329 |
330 | getMouseCoords(event) {
331 | const rect = event.target.getBoundingClientRect();
332 | const x = event.clientX - rect.left;
333 | const y = event.clientY - rect.top;
334 |
335 | const col = Math.floor(x / this.renderer.cellWidth);
336 | const row = Math.floor(y / this.renderer.cellHeight);
337 |
338 | return {
339 | row: Math.max(0, Math.min(row, this.renderer.rows - 1)),
340 | col: Math.max(0, Math.min(col, this.renderer.cols - 1)),
341 | };
342 | }
343 |
344 | sendMouseEvent(action, row, col, button) {
345 | if (!this.connected || !this.ws) return;
346 |
347 | this.ws.send(
348 | JSON.stringify({
349 | type: "mouse",
350 | action: action,
351 | button: button,
352 | row: row,
353 | col: col,
354 | immediate: true,
355 | }),
356 | );
357 | }
358 |
359 | sendScrollEvent(direction, row, col) {
360 | if (!this.connected || !this.ws) return;
361 |
362 | this.ws.send(
363 | JSON.stringify({
364 | type: "scroll",
365 | direction: direction,
366 | row: row,
367 | col: col,
368 | immediate: true,
369 | }),
370 | );
371 | }
372 |
373 | setupKeyboardHandlers() {
374 | document.addEventListener("DOMContentLoaded", () => {
375 | const terminal = document.getElementById("terminal");
376 | if (!terminal) {
377 | console.error("Terminal element not found");
378 | return;
379 | }
380 |
381 | terminal.addEventListener("keydown", (event) => {
382 | if (!this.connected) return;
383 | event.preventDefault();
384 | const key = this.translateKey(event);
385 | if (key) {
386 | this.sendInput(key);
387 | }
388 | });
389 |
390 | terminal.focus();
391 | });
392 | }
393 |
394 | translateKey(event) {
395 | const { key, code, ctrlKey, altKey, shiftKey, metaKey } = event;
396 |
397 | // for See
398 | // https://pkg.go.dev/github.com/neovim/go-client/nvim#Nvim.Input
399 | const specialKeys = {
400 | Enter: "",
401 | Escape: "",
402 | Backspace: "",
403 | Tab: "",
404 | Delete: "",
405 | Insert: "",
406 | Home: "",
407 | End: "",
408 | PageUp: "",
409 | PageDown: "",
410 | ArrowUp: "",
411 | ArrowDown: "",
412 | ArrowLeft: "",
413 | ArrowRight: "",
414 | " ": "",
415 | "<": "",
416 | };
417 |
418 | for (let i = 1; i <= 12; i++) {
419 | specialKeys[`F${i}`] = ``;
420 | }
421 |
422 | let modifiers = "";
423 |
424 | if (ctrlKey) {
425 | modifiers += "C-";
426 | }
427 |
428 | if (altKey) {
429 | modifiers += "A-";
430 | }
431 |
432 | if (metaKey) {
433 | modifiers += "D-";
434 | }
435 |
436 | if (shiftKey && !this.isShiftableKey(key)) {
437 | modifiers += "S-";
438 | }
439 |
440 | if (specialKeys[key]) {
441 | if (modifiers) {
442 | return `<${modifiers}${specialKeys[key].slice(1, -1)}>`;
443 | }
444 | return specialKeys[key];
445 | }
446 |
447 | if (key.length === 1) {
448 | if (modifiers) {
449 | if (ctrlKey && !altKey && !metaKey) {
450 | return ``;
451 | }
452 | return `<${modifiers}${key}>`;
453 | }
454 | return key;
455 | }
456 |
457 | if (code.startsWith("Numpad")) {
458 | const numpadKeys = {
459 | Numpad0: "0",
460 | Numpad1: "1",
461 | Numpad2: "2",
462 | Numpad3: "3",
463 | Numpad4: "4",
464 | Numpad5: "5",
465 | Numpad6: "6",
466 | Numpad7: "7",
467 | Numpad8: "8",
468 | Numpad9: "9",
469 | NumpadDecimal: ".",
470 | NumpadAdd: "+",
471 | NumpadSubtract: "-",
472 | NumpadMultiply: "*",
473 | NumpadDivide: "/",
474 | NumpadEnter: "",
475 | };
476 |
477 | if (numpadKeys[code]) {
478 | if (modifiers) {
479 | return `<${modifiers}${numpadKeys[code]}>`;
480 | }
481 | return numpadKeys[code];
482 | }
483 | }
484 |
485 | console.log("Unhandled key:", {
486 | key,
487 | code,
488 | ctrlKey,
489 | altKey,
490 | shiftKey,
491 | metaKey,
492 | });
493 | return null;
494 | }
495 |
496 | isShiftableKey(key) {
497 | const shiftableKeys = [
498 | "!",
499 | "@",
500 | "#",
501 | "$",
502 | "%",
503 | "^",
504 | "&",
505 | "*",
506 | "(",
507 | ")",
508 | "_",
509 | "+",
510 | "{",
511 | "}",
512 | "|",
513 | ":",
514 | '"',
515 | "<",
516 | ">",
517 | "?",
518 | "~",
519 | "A",
520 | "B",
521 | "C",
522 | "D",
523 | "E",
524 | "F",
525 | "G",
526 | "H",
527 | "I",
528 | "J",
529 | "K",
530 | "L",
531 | "M",
532 | "N",
533 | "O",
534 | "P",
535 | "Q",
536 | "R",
537 | "S",
538 | "T",
539 | "U",
540 | "V",
541 | "W",
542 | "X",
543 | "Y",
544 | "Z",
545 | ];
546 | return shiftableKeys.includes(key) || key.length === 1;
547 | }
548 |
549 | setupResizeHandler() {
550 | let resizeTimeout;
551 |
552 | const handleResize = () => {
553 | if (!this.connected || !this.renderer) return;
554 |
555 | const canvas = document.getElementById("terminal");
556 | if (!canvas) return;
557 |
558 | const connectionForm = document.getElementById("connection-form");
559 | const isFormVisible = connectionForm &&
560 | connectionForm.style.display !== "none";
561 |
562 | let containerWidth, containerHeight;
563 |
564 | if (isFormVisible) {
565 | const formHeight = connectionForm.offsetHeight + 40;
566 | containerWidth = globalThis.innerWidth;
567 | containerHeight = globalThis.innerHeight - formHeight - 40;
568 | } else {
569 | containerWidth = globalThis.innerWidth;
570 | containerHeight = globalThis.innerHeight - 40;
571 | }
572 |
573 | canvas.style.width = containerWidth + "px";
574 | canvas.style.height = containerHeight + "px";
575 |
576 | const newDimensions = this.renderer.resize(
577 | containerWidth,
578 | containerHeight,
579 | );
580 | this.sendResize(newDimensions.width, newDimensions.height);
581 | };
582 |
583 | globalThis.addEventListener("resize", () => {
584 | clearTimeout(resizeTimeout);
585 | resizeTimeout = setTimeout(handleResize, 100);
586 | });
587 |
588 | setTimeout(handleResize, 100);
589 | }
590 |
591 | sendResize(width, height) {
592 | if (
593 | this.connected && this.ws && this.ws.readyState === WebSocket.OPEN
594 | ) {
595 | this.ws.send(
596 | JSON.stringify({
597 | type: "resize",
598 | width: width,
599 | height: height,
600 | immediate: true,
601 | }),
602 | );
603 | }
604 | }
605 |
606 | connectToNeovim(address) {
607 | if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
608 | this.updateStatus("Reconnecting to server...");
609 | this.connect();
610 |
611 | const checkConnection = setInterval(() => {
612 | if (this.ws && this.ws.readyState === WebSocket.OPEN) {
613 | clearInterval(checkConnection);
614 | this.ws.send(
615 | JSON.stringify({
616 | type: "connect",
617 | address: address,
618 | }),
619 | );
620 | }
621 | }, 100);
622 |
623 | setTimeout(() => {
624 | clearInterval(checkConnection);
625 | if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
626 | this.updateStatus("Failed to reconnect to server");
627 | }
628 | }, 5000);
629 | } else {
630 | this.ws.send(
631 | JSON.stringify({
632 | type: "connect",
633 | address: address,
634 | }),
635 | );
636 | }
637 | }
638 |
639 | updateStatus(message) {
640 | const statusDiv = document.getElementById("status");
641 | if (statusDiv) {
642 | statusDiv.textContent = message;
643 | }
644 | }
645 |
646 | sendInput(input) {
647 | if (
648 | this.connected && this.ws && this.ws.readyState === WebSocket.OPEN
649 | ) {
650 | this.ws.send(
651 | JSON.stringify({
652 | type: "input",
653 | data: input,
654 | immediate: true,
655 | }),
656 | );
657 | }
658 | }
659 |
660 | sendCommand(command) {
661 | if (this.connected && this.ws) {
662 | this.ws.send(
663 | JSON.stringify({
664 | type: "command",
665 | data: command,
666 | immediate: true,
667 | }),
668 | );
669 | }
670 | }
671 |
672 | async syncToSystemClipboard(text) {
673 | if (!this.clipboardEnabled || text === this.lastClipboardContent) {
674 | return;
675 | }
676 | try {
677 | await navigator.clipboard.writeText(text);
678 | this.lastClipboardContent = text;
679 | } catch (err) {
680 | console.error("Failed to sync clipboard:", err);
681 | }
682 | }
683 |
684 | async sendClipboardToNeovim() {
685 | if (!this.clipboardEnabled) {
686 | return;
687 | }
688 | try {
689 | const text = await navigator.clipboard.readText();
690 | if (this.connected && this.ws) {
691 | this.ws.send(
692 | JSON.stringify({
693 | type: "clipboard_content",
694 | data: text,
695 | }),
696 | );
697 | }
698 | } catch (err) {
699 | console.error("Failed to read clipboard:", err);
700 | // Send empty content on error
701 | if (this.connected && this.ws) {
702 | this.ws.send(
703 | JSON.stringify({
704 | type: "clipboard_content",
705 | data: "",
706 | }),
707 | );
708 | }
709 | }
710 | }
711 |
712 | async requestClipboardPermission() {
713 | if (!navigator.clipboard || !globalThis.isSecureContext) {
714 | console.warn("Clipboard API not available (requires HTTPS)");
715 | return false;
716 | }
717 | try {
718 | const permission = await navigator.permissions.query({
719 | name: "clipboard-read",
720 | });
721 | if (permission.state === "granted") {
722 | this.clipboardEnabled = true;
723 | return true;
724 | } else if (permission.state === "prompt") {
725 | await navigator.clipboard.readText();
726 | this.clipboardEnabled = true;
727 | return true;
728 | }
729 | } catch (_err) {
730 | this.clipboardEnabled = false;
731 | }
732 | return false;
733 | }
734 | }
735 |
736 | if (typeof module !== "undefined" && module.exports) {
737 | module.exports = NeovimClient;
738 | }
739 |
--------------------------------------------------------------------------------
/server/static/renderer.js:
--------------------------------------------------------------------------------
1 | class NeovimRenderer {
2 | constructor(canvas) {
3 | this.canvas = canvas;
4 | this.ctx = canvas.getContext("2d");
5 | this.fontFamily = "monospace";
6 | this.fontSize = 14;
7 | this.cellWidth = 12;
8 | this.cellHeight = 20;
9 | this.rows = 24;
10 | this.cols = 80;
11 | this.grid = [];
12 | this.cursor = { row: 0, col: 0 };
13 | this.cursorMode = "normal";
14 | this.cursorVisible = true;
15 | this.renderPending = false;
16 | this.lastRenderTime = 0;
17 | this.targetFPS = 60;
18 | this.frameInterval = 1000 / this.targetFPS;
19 | this.colors = {
20 | fg: "#ffffff",
21 | bg: "#000000",
22 | };
23 | this.highlights = new Map();
24 |
25 | this.initGrid();
26 | this.setupCanvas();
27 | this.updateFont();
28 | this.startCursorBlink();
29 | }
30 |
31 | updateFont() {
32 | this.ctx.font = `${this.fontSize}px ${this.fontFamily}`;
33 | this.ctx.textBaseline = "top";
34 |
35 | this.cellWidth = Math.round(this.fontSize * 0.6);
36 | this.cellHeight = Math.ceil(this.fontSize * 1.2);
37 |
38 | const currentWidth = this.canvas.width || this.canvas.offsetWidth;
39 | const currentHeight = this.canvas.height || this.canvas.offsetHeight;
40 |
41 | this.cols = Math.floor(currentWidth / this.cellWidth);
42 | this.rows = Math.floor(currentHeight / this.cellHeight);
43 |
44 | this.initGrid();
45 | this.redraw();
46 |
47 | if (globalThis.client && globalThis.client.connected) {
48 | globalThis.client.sendResize(this.cols, this.rows);
49 | }
50 | }
51 |
52 | setFont(fontString) {
53 | const fontMatch = fontString.match(/^([^:]+)(?::h(\d+))?$/) ||
54 | fontString.match(/^([^\d]+)\s+(\d+)$/);
55 |
56 | if (fontMatch) {
57 | const fontFamily = fontMatch[1].trim();
58 | const newFontSize = parseInt(fontMatch[2]) || 12;
59 |
60 | if (newFontSize !== this.fontSize) {
61 | this.fontSize = newFontSize;
62 | }
63 |
64 | this.fontFamily = `${fontFamily},monospace`;
65 | }
66 |
67 | this.updateFont();
68 |
69 | // Notify client to send resize to server
70 | if (globalThis.client && globalThis.client.connected) {
71 | globalThis.client.sendResize(this.cols, this.rows);
72 | }
73 | }
74 |
75 | initGrid() {
76 | this.grid = Array(this.rows)
77 | .fill()
78 | .map(() =>
79 | Array(this.cols)
80 | .fill()
81 | .map(() => ({
82 | char: " ",
83 | fg: this.colors.fg,
84 | bg: this.colors.bg,
85 | }))
86 | );
87 | }
88 |
89 | setupCanvas() {
90 | const dpr = globalThis.devicePixelRatio || 1;
91 |
92 | this.canvas.width = this.cols * this.cellWidth * dpr;
93 | this.canvas.height = this.rows * this.cellHeight * dpr;
94 |
95 | this.canvas.style.width = this.cols * this.cellWidth + "px";
96 | this.canvas.style.height = this.rows * this.cellHeight + "px";
97 |
98 | this.ctx.scale(dpr, dpr);
99 | this.ctx.font = `${this.fontSize}px ${this.fontFamily}`;
100 | this.ctx.textBaseline = "top";
101 |
102 | this.ctx.imageSmoothingEnabled = false;
103 | this.ctx.textRenderingOptimization = "optimizeQuality";
104 | this.ctx.fillStyle = this.colors.fg;
105 |
106 | this.clear();
107 | }
108 |
109 | clear() {
110 | this.ctx.fillStyle = this.colors.bg;
111 | this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
112 | }
113 |
114 | isWideChar(char) {
115 | if (!char || char.length === 0) return false;
116 |
117 | const code = char.codePointAt(0);
118 | if (!code) return false;
119 |
120 | // Use a more comprehensive check including Unicode East Asian Width property
121 | return (
122 | // CJK Unified Ideographs
123 | (code >= 0x4e00 && code <= 0x9fff) ||
124 | // CJK Extension A
125 | (code >= 0x3400 && code <= 0x4dbf) ||
126 | // CJK Extension B
127 | (code >= 0x20000 && code <= 0x2a6df) ||
128 | // Hangul Syllables
129 | (code >= 0xac00 && code <= 0xd7af) ||
130 | // Hiragana
131 | (code >= 0x3040 && code <= 0x309f) ||
132 | // Katakana
133 | (code >= 0x30a0 && code <= 0x30ff) ||
134 | // Emoji ranges (comprehensive)
135 | (code >= 0x1f600 && code <= 0x1f64f) || // Emoticons
136 | (code >= 0x1f300 && code <= 0x1f5ff) || // Misc Symbols
137 | (code >= 0x1f680 && code <= 0x1f6ff) || // Transport
138 | (code >= 0x1f1e0 && code <= 0x1f1ff) || // Flags
139 | (code >= 0x2600 && code <= 0x26ff) || // Misc symbols
140 | (code >= 0x2700 && code <= 0x27bf) || // Dingbats
141 | (code >= 0x1f900 && code <= 0x1f9ff) || // Supplemental Symbols
142 | (code >= 0x1fa70 && code <= 0x1faff) || // Extended-A
143 | (code >= 0x2e80 && code <= 0x2eff) || // CJK Radicals
144 | (code >= 0x2f00 && code <= 0x2fdf) || // Kangxi Radicals
145 | (code >= 0x3000 && code <= 0x303f) || // CJK Symbols
146 | (code >= 0xff00 && code <= 0xffef) || // Halfwidth/Fullwidth Forms
147 | // Nerd Fonts icon ranges
148 | (code >= 0x23fb && code <= 0x23fe) || // IEC Power Symbols
149 | code === 0x2665 || // Octicons (heart)
150 | code === 0x26a1 || // Octicons (lightning)
151 | code === 0x2b58 || // IEC Power Symbols
152 | (code >= 0xe000 && code <= 0xe00a) || // Pomicons
153 | (code >= 0xe0a0 && code <= 0xe0a2) || // Powerline
154 | code === 0xe0a3 || // Powerline Extra
155 | (code >= 0xe0b0 && code <= 0xe0b3) || // Powerline
156 | (code >= 0xe0b4 && code <= 0xe0c8) || // Powerline Extra
157 | code === 0xe0ca || // Powerline Extra
158 | (code >= 0xe0cc && code <= 0xe0d7) || // Powerline Extra
159 | (code >= 0xe200 && code <= 0xe2a9) || // Font Awesome Extension
160 | (code >= 0xe300 && code <= 0xe3e3) || // Weather Icons
161 | (code >= 0xe5fa && code <= 0xe6b7) || // Seti-UI + Custom
162 | (code >= 0xe700 && code <= 0xe8ef) || // Devicons
163 | (code >= 0xea60 && code <= 0xec1e) || // Codicons
164 | (code >= 0xed00 && code <= 0xefce) || // Font Awesome (relocated)
165 | (code >= 0xf000 && code <= 0xf2ff) || // Font Awesome (relocated)
166 | (code >= 0xf300 && code <= 0xf381) || // Font Logos
167 | (code >= 0xf400 && code <= 0xf533) || // Octicons (relocated)
168 | (code >= 0xf500 && code <= 0xfd46) || // Material Design (old range)
169 | (code >= 0xf0001 && code <= 0xf1af0) // Material Design (new range)
170 | );
171 | }
172 |
173 | drawCursor() {
174 | if (!this.cursorVisible) return;
175 |
176 | const x = this.cursor.col * this.cellWidth;
177 | const y = this.cursor.row * this.cellHeight;
178 |
179 | this.ctx.fillStyle = "#ffffff";
180 |
181 | const modeStyle = this.modeStyles && this.currentModeIndex !== undefined
182 | ? this.modeStyles[this.currentModeIndex]
183 | : null;
184 | const cursorShape = modeStyle
185 | ? modeStyle.cursorShape
186 | : this.getCursorShapeForMode(this.cursorMode);
187 |
188 | switch (cursorShape) {
189 | case "block": {
190 | this.ctx.fillRect(x, y, this.cellWidth, this.cellHeight);
191 | const cell = this.grid[this.cursor.row] &&
192 | this.grid[this.cursor.row][this.cursor.col];
193 | if (cell && cell.char && cell.char !== " ") {
194 | this.ctx.fillStyle = cell.bg || this.colors.bg;
195 | this.ctx.fillText(cell.char, x, y + 2);
196 | }
197 | break;
198 | }
199 | case "vertical": {
200 | this.ctx.fillRect(x, y, 2, this.cellHeight);
201 | break;
202 | }
203 | case "horizontal": {
204 | const height = Math.max(2, Math.floor(this.cellHeight * 0.2));
205 | this.ctx.fillRect(
206 | x,
207 | y + this.cellHeight - height,
208 | this.cellWidth,
209 | height,
210 | );
211 | break;
212 | }
213 | default: {
214 | this.ctx.fillRect(
215 | x,
216 | y + this.cellHeight - 2,
217 | this.cellWidth,
218 | 2,
219 | );
220 | }
221 | }
222 | }
223 |
224 | getCursorShapeForMode(mode) {
225 | switch (mode) {
226 | case "normal":
227 | case "visual":
228 | case "select":
229 | return "block";
230 | case "insert":
231 | return "vertical";
232 | case "replace":
233 | return "horizontal";
234 | case "cmdline":
235 | case "cmdline_normal":
236 | return "horizontal";
237 | default:
238 | return "block";
239 | }
240 | }
241 |
242 | startCursorBlink() {
243 | if (this.cursorBlinkTimer) {
244 | clearInterval(this.cursorBlinkTimer);
245 | }
246 |
247 | const modeStyle = this.modeStyles && this.currentModeIndex !== undefined
248 | ? this.modeStyles[this.currentModeIndex]
249 | : null;
250 |
251 | if (modeStyle && (modeStyle.blinkon > 0 || modeStyle.blinkoff > 0)) {
252 | this.cursorBlinkTimer = setInterval(() => {
253 | this.cursorVisible = !this.cursorVisible;
254 | this.redraw();
255 | }, modeStyle.blinkon || 500);
256 | } else {
257 | this.cursorVisible = true;
258 | }
259 | }
260 |
261 | handleRedrawEvent(event) {
262 | if (!Array.isArray(event) || event.length === 0) {
263 | console.log("Invalid event format:", event);
264 | return;
265 | }
266 |
267 | const eventType = event[0];
268 | const eventData = event.slice(1);
269 |
270 | switch (eventType) {
271 | case "mode_change":
272 | this.handleModeChange(eventData);
273 | break;
274 | case "mode_info_set":
275 | this.handleModeInfoSet(eventData);
276 | break;
277 | case "option_set":
278 | this.handleOptionSet(eventData);
279 | break;
280 | case "grid_resize":
281 | this.handleGridResize(eventData);
282 | break;
283 | case "grid_line":
284 | this.handleGridLine(eventData);
285 | break;
286 | case "grid_cursor_goto":
287 | this.handleCursorGoto(eventData);
288 | break;
289 | case "grid_clear":
290 | this.handleGridClear();
291 | break;
292 | case "default_colors_set":
293 | this.handleDefaultColors(eventData);
294 | break;
295 | case "flush":
296 | this.requestRedraw();
297 | break;
298 | case "hl_attr_define":
299 | this.handleHlAttrDefine(eventData);
300 | break;
301 | case "win_viewport":
302 | this.handleWinViewport(eventData);
303 | break;
304 | case "grid_scroll":
305 | this.handleGridScroll(eventData);
306 | break;
307 | case "hl_group_set":
308 | break;
309 | case "chdir":
310 | break;
311 | default:
312 | console.log("Unhandled event type:", eventType, eventData);
313 | }
314 | }
315 |
316 | handleModeChange(eventData) {
317 | for (const modeData of eventData) {
318 | const [mode, modeIdx] = modeData;
319 | this.cursorMode = mode;
320 | this.currentModeIndex = modeIdx;
321 | this.startCursorBlink();
322 | }
323 | }
324 |
325 | handleModeInfoSet(eventData) {
326 | for (const modeInfo of eventData) {
327 | const [cursorStyleEnabled, modeInfoList] = modeInfo;
328 | if (cursorStyleEnabled && Array.isArray(modeInfoList)) {
329 | this.modeStyles = {};
330 | modeInfoList.forEach((info, idx) => {
331 | if (info && typeof info === "object") {
332 | this.modeStyles[idx] = {
333 | cursorShape: info.cursor_shape || "block",
334 | cellPercentage: info.cell_percentage || 100,
335 | blinkwait: info.blinkwait || 0,
336 | blinkon: info.blinkon || 0,
337 | blinkoff: info.blinkoff || 0,
338 | };
339 | }
340 | });
341 | this.startCursorBlink();
342 | }
343 | }
344 | }
345 |
346 | handleGridScroll(eventData) {
347 | for (const scrollData of eventData) {
348 | if (!Array.isArray(scrollData) || scrollData.length < 7) continue;
349 |
350 | const [grid, top, bot, left, right, rows, _cols] = scrollData;
351 |
352 | if (grid !== 1) continue;
353 |
354 | if (rows > 0) {
355 | for (let row = top; row < bot - rows; row++) {
356 | for (let col = left; col < right; col++) {
357 | if (row + rows < this.rows && col < this.cols) {
358 | this.grid[row][col] = this.grid[row + rows][col];
359 | }
360 | }
361 | }
362 | for (let row = bot - rows; row < bot; row++) {
363 | for (let col = left; col < right; col++) {
364 | if (row < this.rows && col < this.cols) {
365 | this.grid[row][col] = {
366 | char: " ",
367 | fg: this.colors.fg,
368 | bg: this.colors.bg,
369 | };
370 | }
371 | }
372 | }
373 | } else if (rows < 0) {
374 | const absRows = Math.abs(rows);
375 | for (let row = bot - 1; row >= top + absRows; row--) {
376 | for (let col = left; col < right; col++) {
377 | if (row - absRows >= 0 && col < this.cols) {
378 | this.grid[row][col] = this.grid[row - absRows][col];
379 | }
380 | }
381 | }
382 | for (let row = top; row < top + absRows; row++) {
383 | for (let col = left; col < right; col++) {
384 | if (row < this.rows && col < this.cols) {
385 | this.grid[row][col] = {
386 | char: " ",
387 | fg: this.colors.fg,
388 | bg: this.colors.bg,
389 | };
390 | }
391 | }
392 | }
393 | }
394 | }
395 | }
396 |
397 | handleWinViewport(eventData) {
398 | for (const viewportData of eventData) {
399 | if (!Array.isArray(viewportData) || viewportData.length < 6) {
400 | continue;
401 | }
402 |
403 | const [grid, _win, topline, botline, curline, curcol] =
404 | viewportData;
405 |
406 | if (grid === 1) {
407 | console.log(
408 | `Viewport: lines ${topline}-${botline}, cursor at ${curline},${curcol}`,
409 | );
410 | }
411 | }
412 | }
413 |
414 | handleOptionSet(eventData) {
415 | for (const optionData of eventData) {
416 | const [name, value] = optionData;
417 | switch (name) {
418 | case "guifont":
419 | if (value && typeof value === "string") {
420 | this.setFont(value);
421 | }
422 | break;
423 | case "linespace":
424 | if (typeof value === "number") {
425 | this.cellHeight = Math.ceil(
426 | this.fontSize * (1.2 + value / 10),
427 | );
428 | this.setupCanvas();
429 | }
430 | break;
431 | default:
432 | console.log(`Unhandled Option ${name} set to:`, value);
433 | break;
434 | }
435 | }
436 | }
437 |
438 | handleHlAttrDefine(eventData) {
439 | for (const hlData of eventData) {
440 | const [id, rgbAttrs, _ctermAttrs, _info] = hlData;
441 |
442 | const attrs = rgbAttrs || {};
443 |
444 | this.highlights.set(id, {
445 | fg: attrs.foreground !== undefined
446 | ? this.rgbToHex(attrs.foreground)
447 | : this.colors.fg,
448 | bg: attrs.background !== undefined
449 | ? this.rgbToHex(attrs.background)
450 | : this.colors.bg,
451 | bold: attrs.bold || false,
452 | italic: attrs.italic || false,
453 | underline: attrs.underline || false,
454 | reverse: attrs.reverse || false,
455 | });
456 | }
457 | }
458 |
459 | handleGridResize(args) {
460 | if (!args || args.length === 0) return;
461 | const [grid, width, height] = args[0] || args;
462 | if (grid === 1) {
463 | this.cols = width;
464 | this.rows = height;
465 | this.initGrid();
466 | this.setupCanvas();
467 | }
468 | }
469 |
470 | handleGridLine(eventData) {
471 | if (!eventData || eventData.length === 0) return;
472 |
473 | // Process each line immediately but store which rows changed
474 | const changedRows = new Set();
475 |
476 | for (const lineData of eventData) {
477 | if (!Array.isArray(lineData) || lineData.length < 4) continue;
478 |
479 | const [grid, row, colStart, cells, _wrap] = lineData;
480 |
481 | if (grid !== 1 || row >= this.rows || row < 0) continue;
482 |
483 | // Process immediately without merging
484 | let col = colStart;
485 | let currentHlId = 0;
486 |
487 | if (cells && Array.isArray(cells)) {
488 | for (const cellData of cells) {
489 | if (col >= this.cols) break;
490 |
491 | let char, hlId, repeatCount;
492 |
493 | if (Array.isArray(cellData)) {
494 | char = cellData[0] || " ";
495 | if (cellData.length > 1 && cellData[1] !== undefined) {
496 | currentHlId = cellData[1];
497 | }
498 | hlId = currentHlId;
499 | repeatCount = cellData.length > 2 ? cellData[2] : 1;
500 | } else {
501 | char = cellData || " ";
502 | hlId = currentHlId;
503 | repeatCount = 1;
504 | }
505 |
506 | for (let i = 0; i < repeatCount && col < this.cols; i++) {
507 | const highlight = this.highlights.get(hlId) || {
508 | fg: this.colors.fg,
509 | bg: this.colors.bg,
510 | };
511 |
512 | this.grid[row][col] = {
513 | char: char,
514 | fg: highlight.fg,
515 | bg: highlight.bg,
516 | isWideChar: this.isWideChar(char),
517 | };
518 |
519 | col++;
520 | }
521 | }
522 | }
523 |
524 | changedRows.add(row);
525 | }
526 | }
527 |
528 | handleCursorGoto(args) {
529 | if (!args || args.length === 0) return;
530 | const [grid, row, col] = args[0] || args;
531 | if (grid === 1) {
532 | const visualCol = this.logicalToVisualCol(row, col);
533 | this.cursor = { row, col: visualCol };
534 | this.redraw();
535 | }
536 | }
537 |
538 | logicalToVisualCol(row, logicalCol) {
539 | if (row >= this.rows || row < 0) return 0;
540 | if (logicalCol < 0) return 0;
541 |
542 | return Math.min(logicalCol, this.cols - 1);
543 | }
544 |
545 | handleDefaultColors(args) {
546 | if (!args || args.length === 0) return;
547 | const [fg, bg] = args[0] || args;
548 | this.colors.fg = this.rgbToHex(fg);
549 | this.colors.bg = this.rgbToHex(bg);
550 | this.clear();
551 | }
552 |
553 | handleGridClear() {
554 | this.initGrid();
555 | this.clear();
556 | }
557 |
558 | rgbToHex(rgb) {
559 | if (rgb === undefined || rgb === null) {
560 | return null;
561 | }
562 | if (rgb === -1) {
563 | return null;
564 | }
565 |
566 | const value = rgb < 0 ? 0xffffff + rgb + 1 : rgb;
567 | return "#" + value.toString(16).padStart(6, "0");
568 | }
569 |
570 | requestRedraw() {
571 | if (this.renderPending) return;
572 |
573 | const now = performance.now();
574 | const timeSinceLastRender = now - this.lastRenderTime;
575 |
576 | if (timeSinceLastRender >= this.frameInterval) {
577 | // Render immediately if enough time has passed
578 | this.renderPending = true;
579 | requestAnimationFrame(() => {
580 | this.redraw();
581 | this.lastRenderTime = performance.now();
582 | this.renderPending = false;
583 | });
584 | } else {
585 | // Schedule render for later
586 | this.renderPending = true;
587 | setTimeout(() => {
588 | requestAnimationFrame(() => {
589 | this.redraw();
590 | this.lastRenderTime = performance.now();
591 | this.renderPending = false;
592 | });
593 | }, this.frameInterval - timeSinceLastRender);
594 | }
595 | }
596 |
597 | redraw() {
598 | this.clear();
599 |
600 | const backgroundBatches = new Map();
601 | const textBatches = new Map();
602 | for (let row = 0; row < this.rows; row++) {
603 | for (let col = 0; col < this.cols; col++) {
604 | const cell = this.grid[row][col];
605 | if (!cell) continue;
606 |
607 | if (cell.bg !== this.colors.bg) {
608 | if (!backgroundBatches.has(cell.bg)) {
609 | backgroundBatches.set(cell.bg, []);
610 | }
611 | backgroundBatches
612 | .get(cell.bg)
613 | .push({
614 | x: col * this.cellWidth,
615 | y: row * this.cellHeight,
616 | });
617 |
618 | if (cell.isWideChar && col + 1 < this.cols) {
619 | backgroundBatches.get(cell.bg).push({
620 | x: (col + 1) * this.cellWidth,
621 | y: row * this.cellHeight,
622 | });
623 | }
624 | }
625 |
626 | // Group text draws
627 | if (cell.char && cell.char !== " ") {
628 | if (!textBatches.has(cell.fg)) {
629 | textBatches.set(cell.fg, []);
630 | }
631 | textBatches.get(cell.fg).push({
632 | char: cell.char,
633 | x: col * this.cellWidth,
634 | y: row * this.cellHeight,
635 | isWideChar: cell.isWideChar,
636 | });
637 | }
638 |
639 | if (cell.isWideChar) col++;
640 | }
641 | }
642 |
643 | for (const [color, rects] of backgroundBatches) {
644 | this.ctx.fillStyle = color;
645 | for (const rect of rects) {
646 | this.ctx.fillRect(
647 | rect.x,
648 | rect.y,
649 | this.cellWidth,
650 | this.cellHeight,
651 | );
652 | }
653 | }
654 |
655 | for (const [color, texts] of textBatches) {
656 | this.ctx.fillStyle = color;
657 | for (const text of texts) {
658 | if (text.isWideChar) {
659 | const oldAlign = this.ctx.textAlign;
660 | this.ctx.textAlign = "left";
661 | this.ctx.fillText(text.char, text.x, text.y + 2);
662 | this.ctx.textAlign = oldAlign;
663 | } else {
664 | this.ctx.fillText(text.char, text.x, text.y + 2);
665 | }
666 | }
667 | }
668 |
669 | this.drawCursor();
670 | }
671 |
672 | resize(width, height) {
673 | const dpr = globalThis.devicePixelRatio || 1;
674 |
675 | this.canvas.style.width = width + "px";
676 | this.canvas.style.height = height + "px";
677 | this.canvas.width = width * dpr;
678 | this.canvas.height = height * dpr;
679 |
680 | this.cols = Math.floor(width / this.cellWidth);
681 | this.rows = Math.floor(height / this.cellHeight);
682 |
683 | this.initGrid();
684 |
685 | this.ctx.scale(dpr, dpr);
686 | this.ctx.font = `${this.fontSize}px ${this.fontFamily}`;
687 | this.ctx.textBaseline = "top";
688 | this.ctx.imageSmoothingEnabled = false;
689 | this.ctx.textRenderingOptimization = "optimizeQuality";
690 |
691 | this.redraw();
692 | return { width: this.cols, height: this.rows };
693 | }
694 | }
695 |
696 | if (typeof module !== "undefined" && module.exports) {
697 | module.exports = NeovimRenderer;
698 | }
699 |
--------------------------------------------------------------------------------