diff --git a/app/consts.ts b/app/consts.ts new file mode 100644 index 0000000..e979bc9 --- /dev/null +++ b/app/consts.ts @@ -0,0 +1,4 @@ +// URL namespace under which the server serves local images. +// The browser rewrites image srcs to this prefix (via pantsdown config) +// and the server maps it back to filesystem paths relative to root. +export const IMAGE_PREFIX = "/__github_preview__/image/"; diff --git a/app/github-preview.ts b/app/github-preview.ts index c29ecf0..772c0a4 100644 --- a/app/github-preview.ts +++ b/app/github-preview.ts @@ -68,6 +68,11 @@ export class GithubPreview { logging: { level: ENV.LOG_LEVEL === "none" ? undefined : ENV.LOG_LEVEL }, }); + nvim.onDisconnect(() => { + // neovim is gone, don't linger as an orphaned server process + process.exit(0); + }); + const props = (await nvim.call("nvim_get_var", ["github_preview_props"])) as PluginProps; if (ENV.IS_DEV) PluginPropsSchema.parse(props); @@ -107,6 +112,9 @@ export class GithubPreview { } async getEntries(path: string): Promise { + // do not return any entries outside of repo root + if (!normalize(this.root + path).startsWith(this.root)) return []; + const currentDir = path.endsWith("/") ? path : dirname(path) + "/"; const paths = await globby(currentDir + "*", { cwd: this.root, @@ -134,7 +142,7 @@ export class GithubPreview { async setCurrPath(path: string): Promise { // do not return any entries outside of repo root const normalized = normalize(this.root + path); - if (normalized.length < this.root.length) return; + if (!normalized.startsWith(this.root)) return; this.currentPath = path; diff --git a/app/nvim/on-content-change.ts b/app/nvim/on-content-change.ts index b95494e..3d196b5 100644 --- a/app/nvim/on-content-change.ts +++ b/app/nvim/on-content-change.ts @@ -1,3 +1,4 @@ +import { relative } from "node:path"; import { type GithubPreview } from "../github-preview.ts"; const NOTIFICATION = "attach_buffer"; @@ -15,19 +16,28 @@ export async function onContentChange( if (attachedBuffer === buffer) attachedBuffer = null; }); - // Notification handler - app.nvim.onNotification(NOTIFICATION, async ([buffer, path]) => { + // Notification handler. + // Handled through a queue: rapid buffer switches could otherwise interleave + // across the awaits below and detach/attach the wrong buffer + let attachQueue = Promise.resolve(); + app.nvim.onNotification(NOTIFICATION, ([buffer, path]) => { if (!path) return; - if (attachedBuffer !== buffer) { - if (attachedBuffer !== null) { - await app.nvim.call("nvim_buf_detach", [attachedBuffer]); - attachedBuffer = null; - } - // attach to buffer to receive content change notifications - const attached = await app.nvim.call("nvim_buf_attach", [buffer, true, {}]); - if (attached) attachedBuffer = buffer; - } + attachQueue = attachQueue + .then(async () => { + if (attachedBuffer === buffer) return; + + if (attachedBuffer !== null) { + await app.nvim.call("nvim_buf_detach", [attachedBuffer]); + attachedBuffer = null; + } + // attach to buffer to receive content change notifications + const attached = await app.nvim.call("nvim_buf_attach", [buffer, true, {}]); + if (attached) attachedBuffer = buffer; + }) + .catch((err: unknown) => { + app.nvim.logger?.error({ attach_buffer_error: err }); + }); }); // Create autocmd to notify us with event "attach_buffer" @@ -53,10 +63,19 @@ export async function onContentChange( async ([buffer, _changedtick, firstline, lastline, linedata, _more]) => { const path = await app.nvim.call("nvim_buf_get_name", [buffer]); const replaceAll = lastline === -1 && firstline === 0; - const deleteCount = lastline - firstline; - const newContent = replaceAll - ? linedata - : app.lines.toSpliced(firstline, deleteCount, ...linedata); + + let newContent: string[]; + if (replaceAll) { + newContent = linedata; + } else if (relative(app.root, path) === app.currentPath) { + const deleteCount = lastline - firstline; + newContent = app.lines.toSpliced(firstline, deleteCount, ...linedata); + } else { + // app.lines mirrors a different file (e.g. browser navigated away), + // an incremental splice would corrupt it. Fetch the full buffer instead. + newContent = await app.nvim.call("nvim_buf_get_lines", [buffer, 0, -1, true]); + } + callback(newContent, path); }, ); diff --git a/app/server/index.ts b/app/server/index.ts index d948357..7c11023 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,5 +1,7 @@ +import { normalize } from "node:path"; import { type Server } from "bun"; import opener from "opener"; +import { IMAGE_PREFIX } from "../consts.ts"; import { type GithubPreview } from "../github-preview.ts"; import index from "../web/index.html"; import { websocketHandler } from "./websocket.ts"; @@ -12,13 +14,23 @@ export function startServer(app: GithubPreview, isDev: boolean): Server { const server = Bun.serve({ port: port, routes: { - "/__github_preview__/image/*": (req) => { + [IMAGE_PREFIX + "*"]: (req: Request) => { app.nvim.logger?.info({ route: req.url }); const pathname = new URL(req.url).pathname; - const filePath = pathname.replace("/__github_preview__/image/", ""); - app.nvim.logger?.info({ filePath: app.root + filePath }); + let filePath: string; + try { + filePath = decodeURIComponent(pathname.replace(IMAGE_PREFIX, "")); + } catch (_err) { + return new Response(null, { status: 400 }); + } + // do not serve any files outside of repo root + const fullPath = normalize(app.root + filePath); + if (!fullPath.startsWith(app.root)) { + return new Response(null, { status: 404 }); + } + app.nvim.logger?.info({ filePath: fullPath }); // images with relative sources - const file = Bun.file(app.root + filePath); + const file = Bun.file(fullPath); return new Response(file); }, [UNALIVE_URL]: async (req) => { diff --git a/app/web/components/markdown/index.tsx b/app/web/components/markdown/index.tsx index dfc25a8..943fd4e 100644 --- a/app/web/components/markdown/index.tsx +++ b/app/web/components/markdown/index.tsx @@ -1,5 +1,6 @@ import { Pantsdown } from "pantsdown"; import { useContext, useEffect, useState } from "react"; +import { IMAGE_PREFIX } from "../../../consts.ts"; import { cn, getFileExt } from "../../utils.ts"; import { websocketContext } from "../websocket-provider/context.ts"; import { BreadCrumbs } from "./breadcrumbs.tsx"; @@ -13,8 +14,6 @@ import { getScrollOffsets, type Offsets } from "./scroll.ts"; const MARKDOWN_CONTAINER_ID = "markdown-container-id"; const MARKDOWN_ELEMENT_ID = "markdown-element-id"; -const IMAGE_PREFIX = "/__github_preview__/image/"; - const pantsdown = new Pantsdown({ renderer: { relativeImageUrlPrefix: IMAGE_PREFIX, diff --git a/bun.lock b/bun.lock index 6226aba..29e3181 100644 --- a/bun.lock +++ b/bun.lock @@ -6,13 +6,13 @@ "name": "github-preview.nvim", "dependencies": { "bun-plugin-tailwind": "^0.1.2", - "bunvim": "1.2.0", + "bunvim": "1.3.0", "clsx": "^2.1.1", "globby": "^16.2.2", "isbinaryfile": "6.0.0", "mermaid": "^11.16.0", "opener": "^1.5.2", - "pantsdown": "2.2.6", + "pantsdown": "2.2.7", "react": "^19.2.7", "react-dom": "^19.2.7", "reconnecting-websocket": "^4.4.0", @@ -497,7 +497,7 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "bunvim": ["bunvim@1.2.0", "", { "dependencies": { "commander": "^15.0.0", "msgpackr": "^2.0.4", "winston": "^3.19.0" }, "bin": { "bunvim": "src/cli/index.ts" } }, "sha512-OY4Iy/CZjV8U0USm1KEyoWrA1qq+5Yhw0tpKuiC8IOPL3aYg3FZe/2I7dKoTLrggvO1Cep7t0SKVLb3RR2A/jA=="], + "bunvim": ["bunvim@1.3.0", "", { "dependencies": { "commander": "^15.0.0", "msgpackr": "^2.0.4", "winston": "^3.19.0" }, "bin": { "bunvim": "src/cli/index.ts" } }, "sha512-USU6GOP07QJ1Qe/9cyWBlNe2hocdLz7rogE/1R+avDhVsb21reOxsUZqKRuLeQXfyEe5t4jYmI8lkBAf595uHA=="], "cachedir": ["cachedir@2.4.0", "", {}, "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ=="], @@ -987,7 +987,7 @@ "package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], - "pantsdown": ["pantsdown@2.2.6", "", { "dependencies": { "github-slugger": "^2.0.0", "highlight.js": "^11.11.1", "katex": "^0.17.0" } }, "sha512-sknLbX0aztlPuyYcKKoNqDTDKCBQJQEKYckfP77P052Gu19gZmh+M9zKNjMwo9umaP0+aOMG3T8mayUJEqY7Qw=="], + "pantsdown": ["pantsdown@2.2.7", "", { "dependencies": { "github-slugger": "^2.0.0", "highlight.js": "^11.11.1", "katex": "^0.18.0" } }, "sha512-iUnxuS53P2P6xo8kDgeQsWv38unhn3FijMjWyU74fSYPPWbnq7s+JZMfmK9U0VqO60yC82kVG5t5bGrWi220ew=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -1611,7 +1611,7 @@ "ora/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - "pantsdown/katex": ["katex@0.17.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw=="], + "pantsdown/katex": ["katex@0.18.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-rZ4Sw94Oja12Ib4+ee7inAr//yxj0G8RaAlh+ZpzfFhwTifGeJbqJ7D0FStcv6EV8DeNNU9Y8rPeg6vPUizlqg=="], "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], diff --git a/lua/github-preview/functions.lua b/lua/github-preview/functions.lua index 4136707..a051df1 100644 --- a/lua/github-preview/functions.lua +++ b/lua/github-preview/functions.lua @@ -39,12 +39,18 @@ M.start = function() return end - -- should look like "/Users/.../github-preview" - local root = Config.value.single_file and "" or vim.fn.finddir(".git", ";") + -- single-file mode may also be enabled as a fallback when no repo is found. + -- keep it local so the fallback doesn't stick to Config.value across starts + local single_file = Config.value.single_file local buffer_name = vim.api.nvim_buf_get_name(0) local init_path = vim.fn.fnamemodify(buffer_name, ":p") + -- search for ".git" upwards starting from the current file's directory, + -- not from cwd: the file being edited may live outside of nvim's cwd. + -- should look like "/Users/.../github-preview/.git" + local root = single_file and "" or vim.fn.finddir(".git", vim.fn.fnamemodify(init_path, ":h") .. ";") + if root == "" then -- if repo root not found or single-file mode is enabled, -- we make sure there's something loaded into the current buffer @@ -58,7 +64,7 @@ M.start = function() -- if no root, we set root to current path root = vim.fn.fnamemodify(init_path, ":h") .. "/" - Config.value.single_file = true + single_file = true else -- if found, path is made absolute & has "/.git/" popped root = vim.fn.fnamemodify(root, ":p:h:h") .. "/" @@ -70,7 +76,7 @@ M.start = function() root = root, path = init_path, }, - config = Config.value, + config = vim.tbl_deep_extend("force", Config.value, { single_file = single_file }), } -- vim.g.github_preview_props is read by bunvim vim.g.github_preview_props = github_preview_props diff --git a/package.json b/package.json index e60bad6..f3a9a35 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,13 @@ }, "dependencies": { "bun-plugin-tailwind": "^0.1.2", - "bunvim": "1.2.0", + "bunvim": "1.3.0", "clsx": "^2.1.1", "globby": "^16.2.2", "isbinaryfile": "6.0.0", "mermaid": "^11.16.0", "opener": "^1.5.2", - "pantsdown": "2.2.6", + "pantsdown": "2.2.7", "react": "^19.2.7", "react-dom": "^19.2.7", "reconnecting-websocket": "^4.4.0",