diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index f93cba77..00000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,30 +0,0 @@ -/** @type {import('eslint').ESLint.Options} */ -module.exports = { - root: true, - ignorePatterns: [".eslintrc.cjs"], - parser: "@typescript-eslint/parser", - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - project: [__dirname + "/app/tsconfig.json"], - }, - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/strict-type-checked", - "plugin:@typescript-eslint/stylistic-type-checked", - ], - rules: { - "@typescript-eslint/no-unused-expressions": "off", - "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/consistent-type-definitions": ["error", "type"], - "@typescript-eslint/no-unused-vars": [ - "error", - { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, - ], - "@typescript-eslint/consistent-type-imports": [ - "warn", - { prefer: "type-imports", fixStyle: "inline-type-imports" }, - ], - }, -}; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 183b24f1..860e117d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,13 +13,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20.x + node-version: 24.x - name: Install bun uses: oven-sh/setup-bun@v2 - name: Install dependencies @@ -34,17 +34,10 @@ jobs: echo "Code not properly formatted. Please run 'bun run format' locally and commit changes." exit 1 fi - - name: Validate tailwind.css - run: | - bun run tailwind:compile - if [[ $(git status --porcelain) ]]; then - echo "Outdated tailwind.css found. Please run 'bun run tailwind:compile' locally and commit changes." - exit 1 - fi - name: Lint & check types run: bun run check - - name: Test - run: bun test + # - name: Test + # run: bun test Release: needs: [Test] @@ -58,11 +51,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20.x + node-version: 24.x - name: Install bun uses: oven-sh/setup-bun@v2 - name: Install dependencies diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..695afe96 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,21 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [".github"], + "printWidth": 100, + "tabWidth": 3, + "sortImports": { + "newlinesBetween": false + }, + "sortTailwindcss": { + "stylesheet": "./app/web/static/app.css", + "functions": ["cn"] + }, + "overrides": [ + { + "files": ["*.json", "*.md"], + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 26b57c6a..00000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -tailwind.css -.github/dependabot.yml diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 081f39bd..00000000 --- a/.prettierrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "tabWidth": 4, - "printWidth": 100, - "plugins": ["prettier-plugin-organize-imports"], - "overrides": [ - { - "files": ["*.json", "*.md", "*.yml"], - "options": { "tabWidth": 2 } - } - ] -} diff --git a/README.md b/README.md index 61887b30..9ddea764 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,21 @@ https://github.com/wallpants/github-preview.nvim/assets/47203170/e332a671-0ee4-4 ## ✨ Features -* [💻 Linux / macOS / WSL](/docs/FEATURES.md#-linux--macos--wsl) -* [🔴 LIVE updates](/docs/FEATURES.md#-live-updates) -* [♻️ Synced Scrolling](/docs/FEATURES.md#%EF%B8%8F-synced-scrolling) -* [🌈 Dark & Light modes](/docs/FEATURES.md#-dark--light-modes) -* [🖍️ Cursorline in Preview](/docs/FEATURES.md#%EF%B8%8F-cursorline-in-preview) -* [📹 Video Support](/docs/FEATURES.md#-video-support) -* [🏞️ Local Image Support](/docs/FEATURES.md#%EF%B8%8F-local-image-support) -* [🧜 Mermaid Support](/docs/FEATURES.md#-mermaid-support) -* [📌 Single-file mode](/docs/FEATURES.md#-single-file-mode) -* [📂 Repository mode](/docs/FEATURES.md#-repository-mode) +- [💻 Linux / macOS / WSL](/docs/FEATURES.md#-linux--macos--wsl) +- [🔴 LIVE updates](/docs/FEATURES.md#-live-updates) +- [♻️ Synced Scrolling](/docs/FEATURES.md#%EF%B8%8F-synced-scrolling) +- [🌈 Dark & Light modes](/docs/FEATURES.md#-dark--light-modes) +- [🖍️ Cursorline in Preview](/docs/FEATURES.md#%EF%B8%8F-cursorline-in-preview) +- [📹 Video Support](/docs/FEATURES.md#-video-support) +- [🏞️ Local Image Support](/docs/FEATURES.md#%EF%B8%8F-local-image-support) +- [🧜 Mermaid Support](/docs/FEATURES.md#-mermaid-support) +- [📌 Single-file mode](/docs/FEATURES.md#-single-file-mode) +- [📂 Repository mode](/docs/FEATURES.md#-repository-mode) ## ✅ Requirements -* [x] [Bun](https://bun.sh) -* [x] [Neovim](https://neovim.io) +- [x] [Bun](https://bun.sh) +- [x] [Neovim](https://neovim.io) ## 📦 Installation diff --git a/app/env.ts b/app/env.ts new file mode 100644 index 00000000..306a68b7 --- /dev/null +++ b/app/env.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const ENV = z + .object({ + NVIM: z.string(), + LOG_LEVEL: z.enum(["debug", "verbose", "info", "none"]), + IS_DEV: z.boolean(), + }) + .parse({ + NVIM: process.env.NVIM, + LOG_LEVEL: process.env.LOG_LEVEL, + IS_DEV: process.env.LOG_LEVEL !== "none", + }); diff --git a/app/github-preview.ts b/app/github-preview.ts index ddbf451f..a6d3fdbc 100644 --- a/app/github-preview.ts +++ b/app/github-preview.ts @@ -1,300 +1,287 @@ +import { existsSync } from "node:fs"; +import { basename, dirname, normalize, resolve } from "node:path"; import { type Server } from "bun"; -import { NVIM_LOG_LEVELS, attach, type LogLevel, type Nvim } from "bunvim"; +import { NVIM_LOG_LEVELS, attach, type Nvim } from "bunvim"; import { globby } from "globby"; import { isBinaryFile } from "isbinaryfile"; -import { existsSync } from "node:fs"; -import { basename, dirname, normalize, resolve } from "node:path"; -import { parse } from "valibot"; -import { startServer } from "./server"; -import { UNALIVE_URL } from "./server/http"; +import { ENV } from "./env"; +import { startServer, UNALIVE_URL } from "./server"; import { EDITOR_EVENTS_TOPIC } from "./server/websocket"; import { - PluginPropsSchema, - type Config, - type ContentChange, - type CustomEvents, - type PluginProps, - type UpdateConfigAction, - type WsServerMessage, + PluginPropsSchema, + type Config, + type ContentChange, + type GithubPreviewConfig, + type PluginProps, + type UpdateConfigAction, + type WsServerMessage, + type CustomEvents, } from "./types"; -const ENV = { - NVIM: process.env["NVIM"], - LOG_LEVEL: process.env["LOG_LEVEL"] as LogLevel | undefined, - DEV: Boolean(process.env["IS_DEV"]), -}; - export class GithubPreview { - nvim: Nvim; - /** - * Neovim autocommand group id, - * under which all autocommands are to be registered - */ - augroupId: number; - /** - * repo root absolute path. - * - * Includes trailing slash - */ - root: string; - /** - * currentPath: relative to root - */ - currentPath: string; - config: { - dotfiles: Config; - overrides: Config; - }; - repoName: string; - server: Server; - cursorLine: null | number = null; - lines: ContentChange["lines"] = []; + nvim: Nvim; + /** + * Neovim autocommand group id, + * under which all autocommands are to be registered + */ + augroupId: number; + /** + * repo root absolute path. + * + * Includes trailing slash + */ + root: string; + /** + * currentPath: relative to root + */ + currentPath: string; + config: GithubPreviewConfig; + repoName: string; + server: Server; + cursorLine: null | number = null; + lines: ContentChange["lines"] = []; - private constructor(nvim: Nvim, augroupId: number, repoName: string, props: PluginProps) { - this.nvim = nvim as Nvim; - this.augroupId = augroupId; - this.repoName = repoName; - this.config = { - dotfiles: Object.assign({}, props.config), - overrides: Object.assign({}, props.config), - }; - this.currentPath = props.init.path.slice(props.init.root.length); - this.root = props.init.root; + private constructor(nvim: Nvim, augroupId: number, repoName: string, props: PluginProps) { + this.nvim = nvim as Nvim; + this.augroupId = augroupId; + this.repoName = repoName; + this.config = { + dotfiles: Object.assign({}, props.config), + overrides: Object.assign({}, props.config), + }; + this.currentPath = props.init.path.slice(props.init.root.length); + this.root = props.init.root; - // must be called at end of constructor, - // because it requires the values set above - this.server = startServer(this); - } + // must be called at end of constructor, + // because it requires the values set above + this.server = startServer(this, ENV.IS_DEV); + } - static async start() { - // we use a static method to initialize GithubPreview instead - // of using the constructor, because async constructors are not a thing - if (!ENV.NVIM) throw Error("socket missing"); + static async start() { + // we use a static method to initialize GithubPreview instead + // of using the constructor, because async constructors are not a thing + if (!ENV.NVIM) throw Error("socket missing"); - const nvim = await attach({ - socket: ENV.NVIM, - client: { name: "github-preview" }, - logging: { level: ENV.LOG_LEVEL }, - }); + const nvim = await attach({ + socket: ENV.NVIM, + client: { name: "github-preview" }, + logging: { level: ENV.LOG_LEVEL === "none" ? undefined : ENV.LOG_LEVEL }, + }); - const props = (await nvim.call("nvim_get_var", ["github_preview_props"])) as PluginProps; - if (ENV.DEV) parse(PluginPropsSchema, props); + const props = (await nvim.call("nvim_get_var", ["github_preview_props"])) as PluginProps; + if (ENV.IS_DEV) PluginPropsSchema.parse(props); - try { - // try to unalive already running instances of github-preview - await fetch(`http://${props.config.host}:${props.config.port}${UNALIVE_URL}`); - // eslint-disable-next-line - } catch (err) {} + try { + // try to unalive already running instances of github-preview + await fetch(`http://${props.config.host}:${props.config.port}${UNALIVE_URL}`); + } catch (_err) {} - const repoName = await GithubPreview.getRepoName({ root: props.init.root }); - const augroupId = await nvim.call("nvim_create_augroup", [ - "github-preview", - { clear: true }, - ]); + const repoName = await GithubPreview.getRepoName({ root: props.init.root }); + const augroupId = await nvim.call("nvim_create_augroup", ["github-preview", { clear: true }]); - return new GithubPreview(nvim, augroupId, repoName, props); - } + return new GithubPreview(nvim, augroupId, repoName, props); + } - static async getRepoName({ root }: { root: string }): Promise { - let repoName = "root"; - const gitConfig = Bun.file(resolve(root, ".git/config")); + static async getRepoName({ root }: { root: string }): Promise { + let repoName = "root"; + const gitConfig = Bun.file(resolve(root, ".git/config")); - if (!(await gitConfig.exists())) { - return repoName; - } + if (!(await gitConfig.exists())) { + return repoName; + } - const lines = (await gitConfig.text()).split("\n"); + const lines = (await gitConfig.text()).split("\n"); - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - if (line === '[remote "origin"]') { - // nextLine = git@github.com:gualcasas/github-preview.nvim.git - const nextLine = lines[i + 1]; - const repo = nextLine?.split(":")[1]?.slice(0, -4).split("/")[1]; - if (repo) repoName = repo; - } - } - return repoName; - } + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (line === '[remote "origin"]') { + // nextLine = git@github.com:gualcasas/github-preview.nvim.git + const nextLine = lines[i + 1]; + const repo = nextLine?.split(":")[1]?.slice(0, -4).split("/")[1]; + if (repo) repoName = repo; + } + } + return repoName; + } - async getEntries(path: string): Promise { - const currentDir = path.endsWith("/") ? path : dirname(path) + "/"; - const paths = await globby(currentDir + "*", { - cwd: this.root, - dot: true, - ignore: [".git"], - gitignore: true, - onlyFiles: false, - markDirectories: true, - }); + async getEntries(path: string): Promise { + const currentDir = path.endsWith("/") ? path : dirname(path) + "/"; + const paths = await globby(currentDir + "*", { + cwd: this.root, + dot: true, + ignore: [".git"], + gitignore: true, + onlyFiles: false, + markDirectories: true, + }); - return paths.sort((a, b) => { - // sort dirs first and then alphabetically - if (a.endsWith("/") && !b.endsWith("/")) return -1; - if (b.endsWith("/") && !a.endsWith("/")) return 1; - if (a > b) return 1; - if (a < b) return -1; - return 0; - }); - } + return paths.sort((a, b) => { + // sort dirs first and then alphabetically + if (a.endsWith("/") && !b.endsWith("/")) return -1; + if (b.endsWith("/") && !a.endsWith("/")) return 1; + if (a > b) return 1; + if (a < b) return -1; + return 0; + }); + } - /** - * Updates this.currentPath & this.lines based on "path" provided. - * returns entries if path is a dir - */ - 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; + /** + * Updates this.currentPath & this.lines based on "path" provided. + * returns entries if path is a dir + */ + 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; - this.currentPath = path; + this.currentPath = path; - // check open buffers and get lines from there before falling back to filesystem. - const bufs = await this.nvim.call("nvim_list_bufs", []); - for (const buf of bufs) { - const name = await this.nvim.call("nvim_buf_get_name", [buf]); - if (name === this.root + this.currentPath) { - const isLoaded = await this.nvim.call("nvim_buf_is_loaded", [buf]); - if (isLoaded) { - this.lines = await this.nvim.call("nvim_buf_get_lines", [buf, 0, -1, true]); - return; - } + // check open buffers and get lines from there before falling back to filesystem. + const bufs = await this.nvim.call("nvim_list_bufs", []); + for (const buf of bufs) { + const name = await this.nvim.call("nvim_buf_get_name", [buf]); + if (name === this.root + this.currentPath) { + const isLoaded = await this.nvim.call("nvim_buf_is_loaded", [buf]); + if (isLoaded) { + this.lines = await this.nvim.call("nvim_buf_get_lines", [buf, 0, -1, true]); + return; } - } + } + } - if (!existsSync(this.root + this.currentPath)) { - this.lines = [`Path: ${this.currentPath}`, "", "ERROR: path not found"]; - return; - } + if (!existsSync(this.root + this.currentPath)) { + this.lines = [`Path: ${this.currentPath}`, "", "ERROR: path not found"]; + return; + } - const isDir = (this.root + this.currentPath).endsWith("/"); - if (isDir) { - const entries = await this.getEntries(this.currentPath); - // search for readme.md - const readmePath = entries.find((e) => basename(e).toLowerCase() === "readme.md"); - if (readmePath) this.currentPath = readmePath; - else { - this.lines = []; - return entries; - } - } + const isDir = (this.root + this.currentPath).endsWith("/"); + if (isDir) { + const entries = await this.getEntries(this.currentPath); + // search for readme.md + const readmePath = entries.find((e) => basename(e).toLowerCase() === "readme.md"); + if (readmePath) this.currentPath = readmePath; + else { + this.lines = []; + return entries; + } + } - if (await isBinaryFile(this.root + this.currentPath)) { - this.lines = [`File: ${this.currentPath}`, "", "ERROR: binary files not yet supported"]; - return; - } + if (await isBinaryFile(this.root + this.currentPath)) { + this.lines = [`File: ${this.currentPath}`, "", "ERROR: binary files not yet supported"]; + return; + } - const file = Bun.file(this.root + this.currentPath); - // limit file size or browser freezes when trying to apply syntax highlight - if (file.size > 500_000) { - this.lines = [`File: ${this.currentPath}`, "", "ERROR: file too large (>500kB)"]; - return; - } + const file = Bun.file(this.root + this.currentPath); + // limit file size or browser freezes when trying to apply syntax highlight + if (file.size > 500_000) { + this.lines = [`File: ${this.currentPath}`, "", "ERROR: file too large (>500kB)"]; + return; + } - const fileContent = await file.text(); - this.lines = fileContent.split("\n"); - return; - } + const fileContent = await file.text(); + this.lines = fileContent.split("\n"); + return; + } - wsSend(m: WsServerMessage) { - this.nvim.logger?.verbose({ OUTGOING_WEBSOCKET: m }); - this.server.publish(EDITOR_EVENTS_TOPIC, JSON.stringify(m)); - } + wsSend(m: WsServerMessage) { + this.nvim.logger?.verbose({ OUTGOING_WEBSOCKET: m }); + this.server.publish(EDITOR_EVENTS_TOPIC, JSON.stringify(m)); + } - async goodbye() { - this.wsSend({ type: "goodbye" }); - await this.nvim.call("nvim_del_augroup_by_id", [this.augroupId]); - await this.nvim.call("nvim_notify", ["github-preview: goodbye", NVIM_LOG_LEVELS.INFO, {}]); - } + async goodbye() { + this.wsSend({ type: "goodbye" }); + await this.nvim.call("nvim_del_augroup_by_id", [this.augroupId]); + await this.nvim.call("nvim_notify", ["github-preview: goodbye", NVIM_LOG_LEVELS.INFO, {}]); + } - async updateConfig([action, value]: UpdateConfigAction) { - let update: Partial = {}; + async updateConfig([action, value]: UpdateConfigAction) { + let update: Partial = {}; - const updateSingleFile = async (dotfileValue: boolean, newValue: boolean) => { - // we need a function to validate single-file mode config updates - // because single-file mode cannot be disabled if plugin launched - // in single-file mode - if (dotfileValue && !newValue) { - await this.nvim.call("nvim_notify", [ - "github-preview: if plugin launched in single-file mode, it cannot be changed.", - NVIM_LOG_LEVELS.WARN, - {}, - ]); - } else { - update.single_file = newValue; - } - }; + const updateSingleFile = async (dotfileValue: boolean, newValue: boolean) => { + // we need a function to validate single-file mode config updates + // because single-file mode cannot be disabled if plugin launched + // in single-file mode + if (dotfileValue && !newValue) { + await this.nvim.call("nvim_notify", [ + "github-preview: if plugin launched in single-file mode, it cannot be changed.", + NVIM_LOG_LEVELS.WARN, + {}, + ]); + } else { + update.single_file = newValue; + } + }; - const { overrides, dotfiles } = this.config; + const { overrides, dotfiles } = this.config; - switch (action) { - case "theme_name": - update.theme = { - ...overrides.theme, - name: value, - }; - break; - case "theme_high_contrast": - update.theme = { - ...overrides.theme, - high_contrast: value === "on", - }; - break; - case "clear_overrides": - update = dotfiles; - break; - case "single_file": { - if (value === "toggle") { - await updateSingleFile(dotfiles.single_file, !overrides.single_file); - } else { - await updateSingleFile(dotfiles.single_file, value === "on" ? true : false); - } - break; + switch (action) { + case "theme_name": + update.theme = { + ...overrides.theme, + name: value, + }; + break; + case "theme_high_contrast": + update.theme = { + ...overrides.theme, + high_contrast: value === "on", + }; + break; + case "clear_overrides": + update = dotfiles; + break; + case "single_file": { + if (value === "toggle") { + await updateSingleFile(dotfiles.single_file, !overrides.single_file); + } else { + await updateSingleFile(dotfiles.single_file, value === "on" ? true : false); + } + break; + } + case "details_tags": + if (value === "toggle") { + update.details_tags_open = !overrides.details_tags_open; + } else { + update.details_tags_open = value === "open" ? true : false; + } + break; + case "scroll": + if (value === "toggle") { + update.scroll = { ...overrides.scroll, disable: !overrides.scroll.disable }; + } else { + update.scroll = { ...overrides.scroll, disable: value === "on" ? false : true }; + } + break; + case "scroll.offset": + update.scroll = { ...overrides.scroll, top_offset_pct: value }; + break; + case "cursorline": + if (value === "toggle") { + update.cursor_line = { + ...overrides.cursor_line, + disable: !overrides.cursor_line.disable, + }; + } else { + update.cursor_line = { + ...overrides.cursor_line, + disable: value === "on" ? false : true, + }; } - case "details_tags": - if (value === "toggle") { - update.details_tags_open = !overrides.details_tags_open; - } else { - update.details_tags_open = value === "open" ? true : false; - } - break; - case "scroll": - if (value === "toggle") { - update.scroll = { ...overrides.scroll, disable: !overrides.scroll.disable }; - } else { - update.scroll = { ...overrides.scroll, disable: value === "on" ? false : true }; - } - break; - case "scroll.offset": - update.scroll = { ...overrides.scroll, top_offset_pct: value }; - break; - case "cursorline": - if (value === "toggle") { - update.cursor_line = { - ...overrides.cursor_line, - disable: !overrides.cursor_line.disable, - }; - } else { - update.cursor_line = { - ...overrides.cursor_line, - disable: value === "on" ? false : true, - }; - } - break; - case "cursorline.color": - update.cursor_line = { - ...overrides.cursor_line, - color: value, - }; - break; - case "cursorline.opacity": - update.cursor_line = { - ...overrides.cursor_line, - opacity: value, - }; - break; - } + break; + case "cursorline.color": + update.cursor_line = { + ...overrides.cursor_line, + color: value, + }; + break; + case "cursorline.opacity": + update.cursor_line = { + ...overrides.cursor_line, + opacity: value, + }; + break; + } - Object.assign(overrides, update); - } + Object.assign(overrides, update); + } } diff --git a/app/global.d.ts b/app/global.d.ts new file mode 100644 index 00000000..94453272 --- /dev/null +++ b/app/global.d.ts @@ -0,0 +1,6 @@ +declare module "*.css"; + +declare module "*.png" { + const value: string; + export default value; +} diff --git a/app/index.ts b/app/index.ts index 8d1a487a..43fa88b2 100644 --- a/app/index.ts +++ b/app/index.ts @@ -4,62 +4,62 @@ import { onBeforeExit } from "./nvim/on-before-exit.ts"; import { onConfigUpdate } from "./nvim/on-config-update.ts"; import { onContentChange } from "./nvim/on-content-change.ts"; import { onCursorMove } from "./nvim/on-cursor-move.ts"; -import { type CustomEvents, type WsServerMessage } from "./types.ts"; +import type { WsServerMessage, CustomEvents } from "./types.ts"; const app = await GithubPreview.start(); onConfigUpdate(app, async (update) => { - await app.updateConfig(update); - app.wsSend({ type: "update_config", config: app.config }); - return null; + await app.updateConfig(update); + app.wsSend({ type: "update_config", config: app.config }); + return null; }); await onBeforeExit(app, async () => { - await app.goodbye(); - return null; + await app.goodbye(); + return null; }); await onCursorMove( - app, - async ([buffer, path, cursor_line]: CustomEvents["notifications"]["cursor_move"]) => { - const relativePath = relative(app.root, path); - if (!path || (app.config.overrides.single_file && relativePath !== app.currentPath)) return; - app.nvim.logger?.verbose({ - ON_CURSOR_MOVE: { buffer, path: relativePath, cursorLine: cursor_line }, - }); + app, + async ([buffer, path, cursor_line]: CustomEvents["notifications"]["cursor_move"]) => { + const relativePath = relative(app.root, path); + if (!path || (app.config.overrides.single_file && relativePath !== app.currentPath)) return; + app.nvim.logger?.verbose({ + ON_CURSOR_MOVE: { buffer, path: relativePath, cursorLine: cursor_line }, + }); - const message: WsServerMessage = { - type: "cursor_move", - cursorLine: cursor_line, - currentPath: relativePath, - }; + const message: WsServerMessage = { + type: "cursor_move", + cursorLine: cursor_line, + currentPath: relativePath, + }; - if (app.currentPath !== relativePath) { - message.lines = await app.nvim.call("nvim_buf_get_lines", [buffer, 0, -1, true]); - app.lines = message.lines; - } + if (app.currentPath !== relativePath) { + message.lines = await app.nvim.call("nvim_buf_get_lines", [buffer, 0, -1, true]); + app.lines = message.lines; + } - app.cursorLine = message.cursorLine; - app.currentPath = message.currentPath; + app.cursorLine = message.cursorLine; + app.currentPath = message.currentPath; - app.wsSend(message); - }, + app.wsSend(message); + }, ); await onContentChange(app, (lines, path) => { - const relativePath = relative(app.root, path); - if (!path || (app.config.overrides.single_file && relativePath !== app.currentPath)) return; - app.nvim.logger?.verbose({ ON_CONTENT_CHANGE: { lines, path: relativePath } }); + const relativePath = relative(app.root, path); + if (!path || (app.config.overrides.single_file && relativePath !== app.currentPath)) return; + app.nvim.logger?.verbose({ ON_CONTENT_CHANGE: { lines, path: relativePath } }); - const message: WsServerMessage = { - type: "content_change", - currentPath: relativePath, - linesCountChange: app.lines.length !== lines.length, - lines, - }; + const message: WsServerMessage = { + type: "content_change", + currentPath: relativePath, + linesCountChange: app.lines.length !== lines.length, + lines, + }; - app.currentPath = message.currentPath; - app.lines = message.lines; + app.currentPath = message.currentPath; + app.lines = message.lines; - app.wsSend(message); + app.wsSend(message); }); diff --git a/app/nvim/on-before-exit.ts b/app/nvim/on-before-exit.ts index 952885d1..03da4955 100644 --- a/app/nvim/on-before-exit.ts +++ b/app/nvim/on-before-exit.ts @@ -1,24 +1,24 @@ import { type Awaitable } from "bunvim"; import { type GithubPreview } from "../github-preview.ts"; -import { type CustomEvents } from "../types.ts"; +import type { CustomEvents } from "../types.ts"; const REQUEST = "before_exit"; export async function onBeforeExit( - app: GithubPreview, - callback: (args: CustomEvents["requests"][typeof REQUEST]) => Awaitable, + app: GithubPreview, + callback: (args: CustomEvents["requests"][typeof REQUEST]) => Awaitable, ) { - // Request handler - app.nvim.onRequest(REQUEST, callback); + // Request handler + app.nvim.onRequest(REQUEST, callback); - // Create autocmd to make RPCRequest - await app.nvim.call("nvim_create_autocmd", [ - ["VimLeavePre"], - { - group: app.augroupId, - desc: "Notify github-preview", - command: `lua + // Create autocmd to make RPCRequest + await app.nvim.call("nvim_create_autocmd", [ + ["VimLeavePre"], + { + group: app.augroupId, + desc: "Notify github-preview", + command: `lua vim.rpcrequest(${app.nvim.channelId}, "${REQUEST}")`, - }, - ]); + }, + ]); } diff --git a/app/nvim/on-config-update.ts b/app/nvim/on-config-update.ts index 486c7398..b1bc9005 100644 --- a/app/nvim/on-config-update.ts +++ b/app/nvim/on-config-update.ts @@ -1,13 +1,13 @@ import { type Awaitable } from "bunvim"; import { type GithubPreview } from "../github-preview.ts"; -import { type CustomEvents } from "../types.ts"; +import type { CustomEvents } from "../types.ts"; const REQUEST = "config_update"; export function onConfigUpdate( - app: GithubPreview, - callback: (args: CustomEvents["requests"][typeof REQUEST]) => Awaitable, + app: GithubPreview, + callback: (args: CustomEvents["requests"][typeof REQUEST]) => Awaitable, ) { - // Request handler - app.nvim.onRequest(REQUEST, callback); + // Request handler + app.nvim.onRequest(REQUEST, callback); } diff --git a/app/nvim/on-content-change.ts b/app/nvim/on-content-change.ts index 98344d86..b91928ed 100644 --- a/app/nvim/on-content-change.ts +++ b/app/nvim/on-content-change.ts @@ -3,64 +3,64 @@ import { type GithubPreview } from "../github-preview.ts"; const NOTIFICATION = "attach_buffer"; export async function onContentChange( - app: GithubPreview, - callback: (content: string[], path: string) => void, + app: GithubPreview, + callback: (content: string[], path: string) => void, ) { - // We attach buffers to receive notifications on content change - let attachedBuffer: null | number = null; + // We attach buffers to receive notifications on content change + let attachedBuffer: null | number = null; - // Buffer may have been implicitly detached - // https://neovim.io/doc/user/api.html#nvim_buf_detach_event - app.nvim.onNotification("nvim_buf_detach_event", ([buffer]) => { - if (attachedBuffer === buffer) attachedBuffer = null; - }); + // Buffer may have been implicitly detached + // https://neovim.io/doc/user/api.html#nvim_buf_detach_event + app.nvim.onNotification("nvim_buf_detach_event", ([buffer]) => { + if (attachedBuffer === buffer) attachedBuffer = null; + }); - // Notification handler - app.nvim.onNotification(NOTIFICATION, async ([buffer, path]) => { - if (!path) return; + // Notification handler + app.nvim.onNotification(NOTIFICATION, async ([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; - } - }); + 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; + } + }); - // Create autocmd to notify us with event "attach_buffer" - await app.nvim.call("nvim_create_autocmd", [ - ["InsertEnter", "TextChanged"], - { - group: app.augroupId, - desc: "Notify github-preview", - command: `lua + // Create autocmd to notify us with event "attach_buffer" + await app.nvim.call("nvim_create_autocmd", [ + ["InsertEnter", "TextChanged"], + { + group: app.augroupId, + desc: "Notify github-preview", + command: `lua local buffer = vim.api.nvim_get_current_buf() local path = vim.api.nvim_buf_get_name(0) vim.rpcnotify(${app.nvim.channelId}, "${NOTIFICATION}", buffer, path)`, - }, - ]); + }, + ]); - // "nvim_buf_lines_event" and "nvim_buf_changedtick_event" events are - // only emitted by neovim if we've attached a buffer. - app.nvim.onNotification( - "nvim_buf_lines_event", - 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); - callback(newContent, path); - }, - ); + // "nvim_buf_lines_event" and "nvim_buf_changedtick_event" events are + // only emitted by neovim if we've attached a buffer. + app.nvim.onNotification( + "nvim_buf_lines_event", + 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); + callback(newContent, path); + }, + ); - app.nvim.onNotification("nvim_buf_changedtick_event", async ([buffer, _changedtick]) => { - const path = await app.nvim.call("nvim_buf_get_name", [buffer]); - const linedata = await app.nvim.call("nvim_buf_get_lines", [buffer, 0, -1, true]); - callback(linedata, path); - }); + app.nvim.onNotification("nvim_buf_changedtick_event", async ([buffer, _changedtick]) => { + const path = await app.nvim.call("nvim_buf_get_name", [buffer]); + const linedata = await app.nvim.call("nvim_buf_get_lines", [buffer, 0, -1, true]); + callback(linedata, path); + }); } diff --git a/app/nvim/on-cursor-move.ts b/app/nvim/on-cursor-move.ts index 13e7eac4..d2e53be9 100644 --- a/app/nvim/on-cursor-move.ts +++ b/app/nvim/on-cursor-move.ts @@ -1,27 +1,27 @@ import { type Awaitable } from "bunvim"; import { type GithubPreview } from "../github-preview.ts"; -import { type CustomEvents } from "../types.ts"; +import type { CustomEvents } from "../types.ts"; const NOTIFICATION = "cursor_move"; export async function onCursorMove( - app: GithubPreview, - callback: (args: CustomEvents["notifications"][typeof NOTIFICATION]) => Awaitable, + app: GithubPreview, + callback: (args: CustomEvents["notifications"][typeof NOTIFICATION]) => Awaitable, ) { - // Notification handler - app.nvim.onNotification(NOTIFICATION, callback); + // Notification handler + app.nvim.onNotification(NOTIFICATION, callback); - // Create autocmd to notify us with event "CursorMove" - await app.nvim.call("nvim_create_autocmd", [ - ["CursorHold", "CursorHoldI"], - { - group: app.augroupId, - desc: "Notify github-preview", - command: `lua + // Create autocmd to notify us with event "CursorMove" + await app.nvim.call("nvim_create_autocmd", [ + ["CursorHold", "CursorHoldI"], + { + group: app.augroupId, + desc: "Notify github-preview", + command: `lua local buffer = vim.api.nvim_get_current_buf() local path = vim.api.nvim_buf_get_name(0) local cursor_line = vim.api.nvim_win_get_cursor(0)[1] vim.rpcnotify(${app.nvim.channelId}, "${NOTIFICATION}", buffer, path, cursor_line)`, - }, - ]); + }, + ]); } diff --git a/app/sample.test.ts b/app/sample.test.ts deleted file mode 100644 index 87bf407e..00000000 --- a/app/sample.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { expect, test } from "bun:test"; - -test("1 + 3", () => { - expect(1 + 3).toBe(4); -}); diff --git a/app/server/http.ts b/app/server/http.ts deleted file mode 100644 index c1c15b01..00000000 --- a/app/server/http.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { type BunPlugin, type Server } from "bun"; -import { type GithubPreview } from "../github-preview"; - -const webRoot = import.meta.dir + "/../web"; -export const UNALIVE_URL = "/unalive"; - -const GP_PREFIX = "/__github_preview__"; - -// mock loader to prevent crashing production build -// during dev, we import the file dev-tailwind.css to enable hot-reload with vite, -// bun does not have a loader for css, -// that's why we manually run `bun run tailwind:compile` -const mockCssLoader: BunPlugin = { - name: "Mock CSS Loader", - setup(builder) { - builder.onLoad({ filter: /\.css$/ }, () => ({ - contents: "", - })); - }, -}; - -export function httpHandler(app: GithubPreview) { - return async (req: Request, server: Server) => { - const upgradedToWs = server.upgrade(req); - if (upgradedToWs) { - // If client (browser) requested to upgrade connection to websocket - // and we successfully upgraded request - return; - } - - const { pathname } = new URL(req.url); - - app.nvim.logger?.verbose({ HTTP: pathname }); - - if (pathname === UNALIVE_URL) { - // This endpoint is called when starting the service to kill - // github-preview instances started by other nvim instances - await app.goodbye(); - app.nvim.detach(); - process.exit(0); - } - - if (pathname.startsWith(GP_PREFIX)) { - // static files (js, img, css) - const requested = pathname.slice(GP_PREFIX.length); - - if (requested.startsWith("/image/")) { - // images with relative sources - const file = Bun.file(app.root + requested.slice("/image/".length)); - return new Response(file); - } - - if (requested === "/index.tsx") { - const { outputs } = await Bun.build({ - entrypoints: [webRoot + requested], - plugins: [mockCssLoader], - define: { - __HOST__: JSON.stringify(app.config.dotfiles.host), - __PORT__: JSON.stringify(app.config.dotfiles.port), - __IS_DEV__: JSON.stringify(false), - __THEME__: JSON.stringify(app.config.overrides.theme), - }, - }); - - return new Response(outputs[0], { - headers: { "content-type": "text/javascript" }, - }); - } - - if (requested === "/pantsdown.css") { - const pantsdownCss = Bun.resolveSync("pantsdown/styles.css", import.meta.dir); - const file = Bun.file(pantsdownCss); - return new Response(file, { - headers: { "content-type": "text/css" }, - }); - } - - if (requested === "/katex.css") { - const katexCss = Bun.resolveSync("katex/dist/katex.min.css", import.meta.dir); - const file = Bun.file(katexCss); - return new Response(file, { - headers: { "content-type": "text/css" }, - }); - } - - if (requested.startsWith("/fonts/KaTeX_")) { - const fontPath = Bun.resolveSync("katex/dist" + requested, import.meta.dir); - const file = Bun.file(fontPath); - const ext = requested.split(".").pop(); - const contentType = - ext === "woff2" - ? "font/woff2" - : ext === "woff" - ? "font/woff" - : "font/ttf"; - return new Response(file, { - headers: { "content-type": contentType }, - }); - } - - if (requested === "/mermaid.js") { - const mermaid = Bun.resolveSync("mermaid/dist/mermaid.min.js", import.meta.dir); - const file = Bun.file(mermaid); - return new Response(file, { - headers: { "content-type": "text/javascript" }, - }); - } - - const file = Bun.file(webRoot + requested); - return new Response(file); - } - - // If none of the previous cases match the request, the client (browser) is - // probably making its first request to get index.html - const index = Bun.file(webRoot + "/index.html"); - return new Response(index); - }; -} diff --git a/app/server/index.ts b/app/server/index.ts index 975d430c..d948357f 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,18 +1,49 @@ import { type Server } from "bun"; import opener from "opener"; import { type GithubPreview } from "../github-preview.ts"; -import { httpHandler } from "./http.ts"; +import index from "../web/index.html"; import { websocketHandler } from "./websocket.ts"; -export function startServer(app: GithubPreview): Server { - const { port, host } = app.config.overrides; +export const UNALIVE_URL = "/unalive"; - const server = Bun.serve({ - port: port, - fetch: httpHandler(app), - websocket: websocketHandler(app), - }); +export function startServer(app: GithubPreview, isDev: boolean): Server { + const { port, host } = app.config.overrides; - opener(`http://${host}:${port}`); - return server; + const server = Bun.serve({ + port: port, + routes: { + "/__github_preview__/image/*": (req) => { + 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 }); + // images with relative sources + const file = Bun.file(app.root + filePath); + return new Response(file); + }, + [UNALIVE_URL]: async (req) => { + app.nvim.logger?.info({ route: req.url }); + // This endpoint is called when starting the service to kill + // github-preview instances started by other nvim instances + await app.goodbye(); + app.nvim.detach(); + process.exit(0); + }, + "/*": index, + }, + fetch: (req: Request, server: Server) => { + app.nvim.logger?.info({ fetchUrl: req.url }); + const upgradedToWs = server.upgrade(req); + if (upgradedToWs) { + // If client (browser) requested to upgrade connection to websocket + // and we successfully upgraded request + return; + } + }, + websocket: websocketHandler(app), + development: isDev, + }); + + opener(`http://${host}:${port}?theme=${JSON.stringify(app.config.overrides.theme)}`); + return server; } diff --git a/app/server/websocket.ts b/app/server/websocket.ts index d70e7163..e0d697f0 100644 --- a/app/server/websocket.ts +++ b/app/server/websocket.ts @@ -4,97 +4,98 @@ import { type WsBrowserMessage, type WsServerMessage } from "../types.ts"; export const EDITOR_EVENTS_TOPIC = "editor_events"; -export function websocketHandler(app: GithubPreview): WebSocketHandler { - return { - open(webSocket) { - // subscribe to a topic so we can publish messages from outside - // webServer.publish(EDITOR_EVENTS_TOPIC, payload); - // - // it doesn't matter who's on the other end of the websocket connection, - // all clients (browsers) share the same app state - webSocket.subscribe(EDITOR_EVENTS_TOPIC); - }, - async message(webSocket, message: string) { - const browserMessage = JSON.parse(message) as WsBrowserMessage; - app.nvim.logger?.verbose({ INCOMING_WEBSOCKET: browserMessage }); +export function websocketHandler(app: GithubPreview): WebSocketHandler { + return { + open(webSocket) { + // subscribe to a topic so we can publish messages from outside + // webServer.publish(EDITOR_EVENTS_TOPIC, payload); + // + // it doesn't matter who's on the other end of the websocket connection, + // all clients (browsers) share the same app state + webSocket.subscribe(EDITOR_EVENTS_TOPIC); + }, + async message(webSocket, message: string) { + const browserMessage = JSON.parse(message) as WsBrowserMessage; + app.nvim.logger?.verbose({ INCOMING_WEBSOCKET: browserMessage }); - function wsSend(m: WsServerMessage) { - app.nvim.logger?.verbose({ OUTGOING_WEBSOCKET: m }); - webSocket.send(JSON.stringify(m)); - } + function wsSend(m: WsServerMessage) { + app.nvim.logger?.verbose({ OUTGOING_WEBSOCKET: m }); + webSocket.send(JSON.stringify(m)); + } - let hash: string | undefined; + let hash: string | undefined; - if ("path" in browserMessage) { - // remove hash from browserMessage.path to prevent filesystem - // operations from failing - const [path, messageHash] = browserMessage.path.split("#"); - browserMessage.path = path!; - hash = messageHash; - } + if ("path" in browserMessage) { + // remove hash from browserMessage.path to prevent filesystem + // operations from failing + const [path, messageHash] = browserMessage.path.split("#"); + // eslint-disable-next-line + browserMessage.path = path!; + hash = messageHash; + } - if (browserMessage.type === "init") { - // call "setCurrPath" in case app started in repository mode and no buffer - // was loaded. "setCurrPath" should resolve to readme.md if it exists. - const entries = await app.setCurrPath(app.currentPath); + if (browserMessage.type === "init") { + // call "setCurrPath" in case app started in repository mode and no buffer + // was loaded. "setCurrPath" should resolve to readme.md if it exists. + const entries = await app.setCurrPath(app.currentPath); - const message: WsServerMessage = { - type: "init", - currentPath: app.currentPath, - repoName: app.repoName, - lines: app.lines, - config: app.config, - cursorLine: app.cursorLine, - currentEntries: entries, - }; - wsSend(message); - } + const message: WsServerMessage = { + type: "init", + currentPath: app.currentPath, + repoName: app.repoName, + lines: app.lines, + config: app.config, + cursorLine: app.cursorLine, + currentEntries: entries, + }; + wsSend(message); + } - if (browserMessage.type === "get_entries") { - const message: WsServerMessage = { - type: "entries", - path: browserMessage.path, - entries: await app.getEntries(browserMessage.path), - }; - wsSend(message); - } + if (browserMessage.type === "get_entries") { + const message: WsServerMessage = { + type: "entries", + path: browserMessage.path, + entries: await app.getEntries(browserMessage.path), + }; + wsSend(message); + } - if (browserMessage.type === "get_entry") { - // if single-file mode is enabled, dont respond if browser requests - // an entry other than the currentPath - if (app.config.overrides.single_file && browserMessage.path !== app.currentPath) { - return; - } + if (browserMessage.type === "get_entry") { + // if single-file mode is enabled, dont respond if browser requests + // an entry other than the currentPath + if (app.config.overrides.single_file && browserMessage.path !== app.currentPath) { + return; + } - const isDir = browserMessage.path.endsWith("/"); + const isDir = browserMessage.path.endsWith("/"); - // is same path, keep cursorLine - const cursorLine = - browserMessage.path === app.currentPath && !isDir ? app.cursorLine : null; + // is same path, keep cursorLine + const cursorLine = + browserMessage.path === app.currentPath && !isDir ? app.cursorLine : null; - // list of entries if path is dir, otherwise undefined - const entries = await app.setCurrPath(browserMessage.path); + // list of entries if path is dir, otherwise undefined + const entries = await app.setCurrPath(browserMessage.path); - const message: WsServerMessage = { - type: "entry", - currentPath: app.currentPath, - lines: app.lines, - cursorLine: cursorLine, - hash: hash, - currentEntries: entries, - }; + const message: WsServerMessage = { + type: "entry", + currentPath: app.currentPath, + lines: app.lines, + cursorLine: cursorLine, + hash: hash, + currentEntries: entries, + }; - wsSend(message); - } + wsSend(message); + } - if (browserMessage.type === "update_config") { - await app.updateConfig(browserMessage.action); - const message: WsServerMessage = { - type: "update_config", - config: app.config, - }; - wsSend(message); - } - }, - }; + if (browserMessage.type === "update_config") { + await app.updateConfig(browserMessage.action); + const message: WsServerMessage = { + type: "update_config", + config: app.config, + }; + wsSend(message); + } + }, + }; } diff --git a/app/types.ts b/app/types.ts index d768958d..4e78e791 100644 --- a/app/types.ts +++ b/app/types.ts @@ -1,184 +1,162 @@ import { type BaseEvents } from "bunvim"; -import { - array, - boolean, - coerce, - literal, - maxValue, - minValue, - number, - object, - string, - union, - type Output, -} from "valibot"; -import { type GithubPreview } from "./github-preview"; +import { z } from "zod"; -export const ThemeSchema = object({ - name: union([literal("system"), literal("light"), literal("dark")]), - high_contrast: boolean(), +export const ThemeSchema = z.object({ + name: z.enum(["system", "light", "dark"]), + high_contrast: z.boolean(), }); -export type Theme = Output; +export type Theme = z.infer; -export const BuildConstsSchema = object({ - HOST: string(), - PORT: coerce(number(), Number), - IS_DEV: coerce(boolean(), Boolean), - THEME: ThemeSchema, +export const BuildConstsSchema = z.object({ + IS_DEV: z.boolean(), + THEME: ThemeSchema, }); -export type BuildConsts = Output; +export type BuildConsts = z.infer; -export const PluginPropsSchema = object({ - init: object({ - /** dir path where ".git" dir was found */ - root: string(), - /** - * current path when plugin was loaded - * if no buffer was loaded when plugin started, path is dir and ends with "/" - * otherwise path looks something like "/Users/.../README.md" - * */ - path: string(), - }), +export const PluginPropsSchema = z.object({ + init: z.object({ + /** dir path where ".git" dir was found */ + root: z.string(), + /** + * current path when plugin was loaded + * if no buffer was loaded when plugin started, path is dir and ends with "/" + * otherwise path looks something like "/Users/.../README.md" + * */ + path: z.string(), + }), - config: object({ - /** http/ws host "localhost" */ - host: string(), - /** port to host the http/ws server "localhost:\{port\}" */ - port: number(), - single_file: boolean(), - theme: ThemeSchema, - details_tags_open: boolean(), - cursor_line: object({ - disable: boolean(), - color: string(), - opacity: number([minValue(0), maxValue(1)]), - }), - scroll: object({ - disable: boolean(), - top_offset_pct: number(), - }), - }), + config: z.object({ + /** http/ws host "localhost" */ + host: z.string(), + /** port to host the http/ws server "localhost:\{port\}" */ + port: z.number(), + single_file: z.boolean(), + theme: ThemeSchema, + details_tags_open: z.boolean(), + cursor_line: z.object({ + disable: z.boolean(), + color: z.string(), + opacity: z.number().min(0).max(1), + }), + scroll: z.object({ + disable: z.boolean(), + top_offset_pct: z.number(), + }), + }), }); -export type PluginProps = Output; +export type PluginProps = z.infer; export type Config = PluginProps["config"]; -export const CursorMoveSchema = object({ - /** - * Used to attach & detach buffers (subscribe to buffer changes) - * as user navigates from buffer to buffer in neovim. - * */ - buffer_id: number(), - abs_path: string(), - cursor_line: number(), -}); -export type CursorMove = Output; - -export const ContentChangeSchema = object({ - abs_path: string(), - lines: array(string()), -}); -export type ContentChange = Output; +export type ContentChange = { + abs_path: string; + lines: string[]; +}; -export type WsServerMessage = - | { - type: "init"; - lines: string[]; - repoName: string; - currentPath: string; - config: GithubPreview["config"]; - cursorLine: number | null; - currentEntries: string[] | undefined; - } - | { - type: "entries"; - path: string; - entries: string[]; - } - | { - type: "entry"; - currentPath: string; - lines: string[]; - cursorLine: number | null; - hash: string | undefined; - currentEntries: string[] | undefined; - } - | { - type: "cursor_move"; - cursorLine: number | null; - currentPath: string; - lines?: string[]; - } - | { - type: "content_change"; - currentPath: string; - /** - * Offset recalculation is triggered on markdown container element resize. - * Sometimes adding new lines doesn't change the element size so offsets - * are not recalculated, leading to incorrect cursorline position. - * To fix that, we notify the browser on linesCountChange - * to trigger offset calculation. - * */ - linesCountChange: boolean; - lines: string[]; - } - | { - type: "update_config"; - config: GithubPreview["config"]; - } - | { - type: "goodbye"; - }; +export type UpdateConfigAction = + | ["clear_overrides"] + | ["theme_name", "system" | "light" | "dark"] + | ["theme_high_contrast", "on" | "off"] + | ["single_file", "toggle" | "on" | "off"] + | ["details_tags", "toggle" | "open" | "closed"] + | ["scroll", "toggle" | "on" | "off"] + | ["scroll.offset", number] + | ["cursorline", "toggle" | "on" | "off"] + | ["cursorline.color", string] + | ["cursorline.opacity", number]; export type WsBrowserMessage = - | { - type: "init"; - } - | { - type: "get_entries"; - path: string; - } - | { - type: "get_entry"; - path: string; - } - | { - type: "update_config"; - action: UpdateConfigAction; - }; + | { + type: "init"; + } + | { + type: "get_entries"; + path: string; + } + | { + type: "get_entry"; + path: string; + } + | { + type: "update_config"; + action: UpdateConfigAction; + }; -export type UpdateConfigAction = - | ["clear_overrides"] - | ["theme_name", "system" | "light" | "dark"] - | ["theme_high_contrast", "on" | "off"] - | ["single_file", "toggle" | "on" | "off"] - | ["details_tags", "toggle" | "open" | "closed"] - | ["scroll", "toggle" | "on" | "off"] - | ["scroll.offset", number] - | ["cursorline", "toggle" | "on" | "off"] - | ["cursorline.color", string] - | ["cursorline.opacity", number]; +export type GithubPreviewConfig = { + dotfiles: Config; + overrides: Config; +}; + +export type WsServerMessage = + | { + type: "init"; + lines: string[]; + repoName: string; + currentPath: string; + config: GithubPreviewConfig; + cursorLine: number | null; + currentEntries: string[] | undefined; + } + | { + type: "entries"; + path: string; + entries: string[]; + } + | { + type: "entry"; + currentPath: string; + lines: string[]; + cursorLine: number | null; + hash: string | undefined; + currentEntries: string[] | undefined; + } + | { + type: "cursor_move"; + cursorLine: number | null; + currentPath: string; + lines?: string[]; + } + | { + type: "content_change"; + currentPath: string; + /** + * Offset recalculation is triggered on markdown container element resize. + * Sometimes adding new lines doesn't change the element size so offsets + * are not recalculated, leading to incorrect cursorline position. + * To fix that, we notify the browser on linesCountChange + * to trigger offset calculation. + * */ + linesCountChange: boolean; + lines: string[]; + } + | { + type: "update_config"; + config: GithubPreviewConfig; + } + | { + type: "goodbye"; + }; // eslint-disable-next-line export interface CustomEvents extends BaseEvents { - requests: { - config_update: UpdateConfigAction; - before_exit: []; - }; - notifications: { - // github-preview - attach_buffer: [buffer: number, path: string]; - cursor_move: [buffer: number, path: string, cursor_line: number]; + requests: { + config_update: UpdateConfigAction; + before_exit: []; + }; + notifications: { + // github-preview + attach_buffer: [buffer: number, path: string]; + cursor_move: [buffer: number, path: string, cursor_line: number]; - // neovim native - nvim_buf_detach_event: [buffer: number]; - nvim_buf_lines_event: [ - buffer: number, - changedtick: number, - firstline: number, - lastline: number, - linedata: string[], - more: boolean, - ]; - nvim_buf_changedtick_event: [buffer: number, changedtick: number]; - }; + // neovim native + nvim_buf_detach_event: [buffer: number]; + nvim_buf_lines_event: [ + buffer: number, + changedtick: number, + firstline: number, + lastline: number, + linedata: string[], + more: boolean, + ]; + nvim_buf_changedtick_event: [buffer: number, changedtick: number]; + }; } diff --git a/app/web/.eslintrc.cjs b/app/web/.eslintrc.cjs deleted file mode 100644 index 059ae8e9..00000000 --- a/app/web/.eslintrc.cjs +++ /dev/null @@ -1,20 +0,0 @@ -/** @type {import('eslint').ESLint.Options} */ -module.exports = { - env: { browser: true }, - ignorePatterns: [".eslintrc.cjs", "*.min.js"], - extends: ["plugin:react-hooks/recommended", "plugin:tailwindcss/recommended"], - settings: { - tailwindcss: { - config: __dirname + "/tailwind.config.cjs", - }, - }, - rules: { - "tailwindcss/no-custom-classname": [ - "warn", - { - whitelist: ["pantsdown", "dark"], - callees: ["cn"], - }, - ], - }, -}; diff --git a/app/web/app.tsx b/app/web/app.tsx index f7692082..f49082a4 100644 --- a/app/web/app.tsx +++ b/app/web/app.tsx @@ -1,29 +1,12 @@ -import "./static/dev-tailwind.css"; - -import { parse } from "valibot"; -import { BuildConstsSchema } from "../types.ts"; import { Explorer } from "./components/explorer/index.tsx"; import { Markdown } from "./components/markdown/index.tsx"; import { WebsocketProvider } from "./components/websocket-provider/provider.tsx"; -// consts defined during Bun build or Vite config -declare const __HOST__: unknown; -declare const __PORT__: unknown; -declare const __IS_DEV__: unknown; -declare const __THEME__: unknown; - -const BUILD_CONSTS = parse(BuildConstsSchema, { - HOST: __HOST__, - PORT: __PORT__, - IS_DEV: __IS_DEV__, - THEME: __THEME__, -}); - export const App = () => ( - -
- - -
-
+ +
+ + +
+
); diff --git a/app/web/components/explorer/entry.tsx b/app/web/components/explorer/entry.tsx index 2a4c5fe2..fd033a65 100644 --- a/app/web/components/explorer/entry.tsx +++ b/app/web/components/explorer/entry.tsx @@ -9,98 +9,94 @@ import { websocketContext } from "../websocket-provider/context.ts"; const iconClassName = "mr-3 h-5 w-5"; const IconMap = { - dir: , - openDir: , - file: , + dir: , + openDir: , + file: , }; type Props = { - path: string; - depth: number; - currentPath: string | null; + path: string; + depth: number; + currentPath: string | null; }; export const EntryComponent = ({ path, depth, currentPath }: Props) => { - const { registerHandler, wsRequest } = useContext(websocketContext); - const [entries, setEntries] = useState([]); - const [isSelected, setIsSelected] = useState(false); - const [expanded, setExpanded] = useState( - // expand root by default ("" is root) - path === "", - ); + const { registerHandler, wsRequest } = useContext(websocketContext); + const [entries, setEntries] = useState([]); + const [isSelected, setIsSelected] = useState(false); + const [expanded, setExpanded] = useState( + // expand root by default ("" is root) + path === "", + ); - const isDir = path === "" || path.endsWith("/"); - const entryName = getEntryName(path); + const isDir = path === "" || path.endsWith("/"); + const entryName = getEntryName(path); - useEffect(() => { - registerHandler(`explorer-${path}`, (message) => { - if (message.type === "entries" && message.path === path) { - setEntries(message.entries); - } - }); - }, [path, registerHandler]); + useEffect(() => { + registerHandler(`explorer-${path}`, (message) => { + if (message.type === "entries" && message.path === path) { + setEntries(message.entries); + } + }); + }, [path, registerHandler]); - useEffect(() => { - if (!isDir) return; - wsRequest({ type: "get_entries", path: path }); - }, [wsRequest, path, isDir]); + useEffect(() => { + if (!isDir) return; + wsRequest({ type: "get_entries", path: path }); + }, [wsRequest, path, isDir]); - useEffect(() => { - const segments = getSegments(currentPath); - let entrySlice = segments.slice(0, depth + 1).join("/"); + useEffect(() => { + const segments = getSegments(currentPath); + let entrySlice = segments.slice(0, depth + 1).join("/"); - if (isDir) { - entrySlice += "/"; - if (entrySlice === path) setExpanded(true); - } + if (isDir) { + entrySlice += "/"; + // eslint-disable-next-line + if (entrySlice === path) setExpanded(true); + } - setIsSelected(currentPath === path); - }, [currentPath, depth, path, isDir]); + setIsSelected(currentPath === path); + }, [currentPath, depth, path, isDir]); - return ( -
- {entryName && ( -
{ - setExpanded(true); - wsRequest({ type: "get_entry", path }); - }} - style={{ paddingLeft: depth * 11 + (isDir ? 0 : 20) }} - className={cn( - "relative group flex h-[34px] cursor-pointer items-center mx-3 rounded-md", - "hover:bg-github-canvas-subtle", - isSelected && "bg-github-canvas-subtle", - )} - > - {isSelected && ( -
- )} - {isDir && ( -
{ - e.stopPropagation(); - setExpanded(!expanded); - }} - className="hover:bg-github-border-default mr-1 flex h-full items-center" - > - -
- )} - {IconMap[isDir ? (expanded ? "openDir" : "dir") : "file"]} - - {entryName} - -
- )} - {expanded && - entries.map((path) => ( - - ))} -
- ); + return ( +
+ {entryName && ( +
{ + setExpanded(true); + wsRequest({ type: "get_entry", path }); + }} + style={{ paddingLeft: depth * 11 + (isDir ? 0 : 20) }} + className={cn( + "group relative mx-3 flex h-[34px] cursor-pointer items-center rounded-md", + "hover:bg-github-canvas-subtle", + isSelected && "bg-github-canvas-subtle", + )} + > + {isSelected && ( +
+ )} + {isDir && ( +
{ + e.stopPropagation(); + setExpanded(!expanded); + }} + className="mr-1 flex h-full items-center hover:bg-github-border-default" + > + +
+ )} + {IconMap[isDir ? (expanded ? "openDir" : "dir") : "file"]} + + {entryName} + +
+ )} + {expanded && + entries.map((path) => ( + + ))} +
+ ); }; diff --git a/app/web/components/explorer/footer.tsx b/app/web/components/explorer/footer.tsx index 8d8c1b8b..408705b6 100644 --- a/app/web/components/explorer/footer.tsx +++ b/app/web/components/explorer/footer.tsx @@ -1,28 +1,29 @@ +import wallpants from "../../static/wallpants-256.png"; import { cn } from "../../utils.ts"; export const Footer = ({ isExpanded }: { isExpanded: boolean }) => ( -
- {isExpanded ? ( -

- with ♥️ by{" "} - - wallpants.io - -

- ) : ( - - + +

+ ) : ( + + + + )} +
); diff --git a/app/web/components/explorer/header.tsx b/app/web/components/explorer/header.tsx index 1248e72d..6b3df78c 100644 --- a/app/web/components/explorer/header.tsx +++ b/app/web/components/explorer/header.tsx @@ -8,59 +8,60 @@ import { SettingsIcon } from "../icons/settings.tsx"; import { websocketContext } from "../websocket-provider/context.ts"; export const Header = ({ - isExpanded, - setIsExpanded, - setConfigOpen, - className, - isOverriden, - setSettingsOffset, + isExpanded, + setIsExpanded, + setConfigOpen, + className, + isOverriden, + setSettingsOffset, }: { - isExpanded: boolean; - setIsExpanded: (e: boolean) => void; - setConfigOpen: Dispatch>; - className: string; - isOverriden: boolean; - setSettingsOffset: (o: number) => void; + isExpanded: boolean; + setIsExpanded: (e: boolean) => void; + setConfigOpen: Dispatch>; + className: string; + isOverriden: boolean; + setSettingsOffset: (o: number) => void; }) => { - const { refObject } = useContext(websocketContext); + const { refObject } = useContext(websocketContext); - return ( -
- {isExpanded && ( - <> -

Files

-
- { - e.stopPropagation(); - setSettingsOffset(105); - setConfigOpen((c) => (c === "no-key" ? null : "no-key")); - }} - /> - {isOverriden ? ( -
- ) : null} -
- - )} - { - refObject.current.skipScroll = true; - setIsExpanded(!isExpanded); - }} - /> -
- ); + return ( +
+ {isExpanded && ( + <> +

Files

+
+ { + e.stopPropagation(); + setSettingsOffset(105); + setConfigOpen((c) => (c === "no-key" ? null : "no-key")); + }} + /> + {isOverriden ? ( +
+ ) : null} +
+ + )} + { + // eslint-disable-next-line + refObject.current.skipScroll = true; + setIsExpanded(!isExpanded); + }} + /> +
+ ); }; diff --git a/app/web/components/explorer/index.tsx b/app/web/components/explorer/index.tsx index 5453dd8a..eb616900 100644 --- a/app/web/components/explorer/index.tsx +++ b/app/web/components/explorer/index.tsx @@ -10,66 +10,64 @@ import { CollapsedSettings } from "./settings/collapsed.tsx"; import { Settings } from "./settings/index.tsx"; export const Explorer = () => { - const { currentPath, config } = useContext(websocketContext); - const [configOpen, setConfigOpen] = useState(null); - const [isExpanded, setIsExpanded] = useState(false); - const [startExit, setStartExit] = useState(false); - const [settingsOffset, setSettingsOffset] = useState(0); - const isOverriden = !isEqual(config?.dotfiles, config?.overrides); + const { currentPath, config } = useContext(websocketContext); + const [configOpen, setConfigOpen] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + const [startExit, setStartExit] = useState(false); + const [settingsOffset, setSettingsOffset] = useState(0); + const isOverriden = !isEqual(config?.dotfiles, config?.overrides); - useOnDocumentClick({ - disabled: !configOpen, - callback: () => { - setConfigOpen(null); - }, - }); + useOnDocumentClick({ + disabled: !configOpen, + callback: () => { + setConfigOpen(null); + }, + }); - return ( -
+
+ {config?.overrides.single_file ? ( +
+

Single-file mode

+

File explorer only available when in repository mode.

+
+ ) : ( + )} - > -
- {config?.overrides.single_file ? ( -
-

Single-file mode

-

File explorer only available when in repository mode.

-
- ) : ( - - )} -
-
+
+ {!isExpanded && ( + - {!isExpanded && ( - - )} - {configOpen ? ( - - ) : null} -
-
- ); + )} + {configOpen ? ( + + ) : null} +
+
+ ); }; diff --git a/app/web/components/explorer/settings/collapsed-option.tsx b/app/web/components/explorer/settings/collapsed-option.tsx index fb8ac6c5..dc44c09a 100644 --- a/app/web/components/explorer/settings/collapsed-option.tsx +++ b/app/web/components/explorer/settings/collapsed-option.tsx @@ -1,10 +1,10 @@ import { - useContext, - useEffect, - useState, - type Dispatch, - type FC, - type SetStateAction, + useContext, + useEffect, + useState, + type Dispatch, + type FC, + type SetStateAction, } from "react"; import { type Config } from "../../../../types"; import { cn, isEqual } from "../../../utils"; @@ -22,113 +22,113 @@ import { UnfoldVerticalIcon } from "../../icons/unfold-vertical"; import { websocketContext } from "../../websocket-provider/context"; type Props = { - cKey: keyof Config; - active: boolean; - setConfigOpen: Dispatch>; - setSettingsOffset: (o: number) => void; - className?: string; - startExit: boolean; + cKey: keyof Config; + active: boolean; + setConfigOpen: Dispatch>; + setSettingsOffset: (o: number) => void; + className?: string; + startExit: boolean; }; const DURATION_SECS = 2.5; export const CollapsedOption = ({ - cKey, - active, - setConfigOpen, - setSettingsOffset, - className, - startExit, + cKey, + active, + setConfigOpen, + setSettingsOffset, + className, + startExit, }: Props) => { - const { config } = useContext(websocketContext); - const [isHovering, setIsHovering] = useState(false); + const { config } = useContext(websocketContext); + const [isHovering, setIsHovering] = useState(false); - useEffect(() => { - if (!active || !startExit || isHovering) return; - const timeout = setTimeout(() => { - setConfigOpen(null); - }, DURATION_SECS * 1000); + useEffect(() => { + if (!active || !startExit || isHovering) return; + const timeout = setTimeout(() => { + setConfigOpen(null); + }, DURATION_SECS * 1000); - return () => { - clearTimeout(timeout); - }; - }, [startExit, setConfigOpen, active, isHovering]); + return () => { + clearTimeout(timeout); + }; + }, [startExit, setConfigOpen, active, isHovering]); - if (!config) return null; + if (!config) return null; - const dotfiles = config.dotfiles[cKey]; - const override = config.overrides[cKey]; - const isOverriden = !isEqual(dotfiles, override); + const dotfiles = config.dotfiles[cKey]; + const override = config.overrides[cKey]; + const isOverriden = !isEqual(dotfiles, override); - const [Icon, iconClassName] = findIcon(cKey, config.overrides); + const [Icon, iconClassName] = findIcon(cKey, config.overrides); - return ( -
- {Icon && ( -
- - { - setIsHovering(true); - }} - onMouseLeave={() => { - setIsHovering(false); - }} - className={cn("mx-auto", active && !startExit && "bg-github-canvas-subtle")} - iconClassName={iconClassName} - noBorder - onClick={(event) => { - event.stopPropagation(); - const element = event.currentTarget; - setSettingsOffset(element.getBoundingClientRect().y); - setConfigOpen((c) => (c === cKey ? null : cKey)); - }} - /> -
- )} - {isOverriden ? ( -
- ) : null} -
- ); + return ( +
+ {Icon && ( +
+ + { + setIsHovering(true); + }} + onMouseLeave={() => { + setIsHovering(false); + }} + className={cn("mx-auto", active && !startExit && "bg-github-canvas-subtle")} + iconClassName={iconClassName} + noBorder + onClick={(event) => { + event.stopPropagation(); + const element = event.currentTarget; + setSettingsOffset(element.getBoundingClientRect().y); + setConfigOpen((c) => (c === cKey ? null : cKey)); + }} + /> +
+ )} + {isOverriden ? ( +
+ ) : null} +
+ ); }; function findIcon( - cKey: Props["cKey"], - config: Config, + cKey: Props["cKey"], + config: Config, ): [Icon: FC<{ className: string }> | null, className: string] { - if (cKey === "theme") { - if (config[cKey].name === "dark") return [MoonIcon, ""]; - if (config[cKey].name === "light") return [SunIcon, ""]; - return [SystemIcon, ""]; - } + if (cKey === "theme") { + if (config[cKey].name === "dark") return [MoonIcon, ""]; + if (config[cKey].name === "light") return [SunIcon, ""]; + return [SystemIcon, ""]; + } - if (cKey === "scroll") { - if (config[cKey].disable) return [MouseIcon, ""]; - return [MouseIcon, "stroke-github-accent-fg"]; - } + if (cKey === "scroll") { + if (config[cKey].disable) return [MouseIcon, ""]; + return [MouseIcon, "stroke-github-accent-fg"]; + } - if (cKey === "single_file") { - if (config[cKey]) return [PinIcon, "stroke-github-danger-fg fill-github-danger-fg"]; - return [PinOffIcon, ""]; - } + if (cKey === "single_file") { + if (config[cKey]) return [PinIcon, "stroke-github-danger-fg fill-github-danger-fg"]; + return [PinOffIcon, ""]; + } - if (cKey === "cursor_line") { - if (config[cKey].disable) return [CursorlineIcon, ""]; - return [CursorlineIcon, "stroke-[#ff00a2]"]; - } + if (cKey === "cursor_line") { + if (config[cKey].disable) return [CursorlineIcon, ""]; + return [CursorlineIcon, "stroke-[#ff00a2]"]; + } - if (cKey === "details_tags_open") { - if (config[cKey]) return [UnfoldVerticalIcon, "stroke-github-success-fg"]; - return [FoldVerticalIcon, ""]; - } + if (cKey === "details_tags_open") { + if (config[cKey]) return [UnfoldVerticalIcon, "stroke-github-success-fg"]; + return [FoldVerticalIcon, ""]; + } - return [null, ""]; + return [null, ""]; } diff --git a/app/web/components/explorer/settings/collapsed.tsx b/app/web/components/explorer/settings/collapsed.tsx index f7eb6068..1f0c17a7 100644 --- a/app/web/components/explorer/settings/collapsed.tsx +++ b/app/web/components/explorer/settings/collapsed.tsx @@ -4,58 +4,58 @@ import { cn } from "../../../utils"; import { CollapsedOption } from "./collapsed-option"; type Props = { - className?: string; - setSettingsOffset: (o: number) => void; - setConfigOpen: Dispatch>; - configOpen: null | keyof Config | "no-key"; - startExit: boolean; + className?: string; + setSettingsOffset: (o: number) => void; + setConfigOpen: Dispatch>; + configOpen: null | keyof Config | "no-key"; + startExit: boolean; }; export const CollapsedSettings = ({ - className, - setSettingsOffset, - setConfigOpen, - configOpen, - startExit, + className, + setSettingsOffset, + setConfigOpen, + configOpen, + startExit, }: Props) => ( -
-
- - - - - -
-
+
+
+ + + + + +
+
); diff --git a/app/web/components/explorer/settings/index.tsx b/app/web/components/explorer/settings/index.tsx index 16b40004..f117eb4b 100644 --- a/app/web/components/explorer/settings/index.tsx +++ b/app/web/components/explorer/settings/index.tsx @@ -9,95 +9,91 @@ import { SingleFileOption } from "./options/single-file"; import { ThemeOption } from "./options/theme"; type Props = { - isOverriden: boolean; - cKey: keyof Config | "no-key"; - settingsOffset: number; - setStartExit: (s: boolean) => void; + isOverriden: boolean; + cKey: keyof Config | "no-key"; + settingsOffset: number; + setStartExit: (s: boolean) => void; }; export const Settings = ({ isOverriden, cKey, settingsOffset, setStartExit }: Props) => { - const { wsRequest } = useContext(websocketContext); - const [isHovering, setIsHovering] = useState(false); - const [isSelectingColor, setIsSelectingColor] = useState(false); - const [tick, setTick] = useState(0); + const { wsRequest } = useContext(websocketContext); + const [isHovering, setIsHovering] = useState(false); + const [isSelectingColor, setIsSelectingColor] = useState(false); + const [tick, setTick] = useState(0); - const smallSettings = cKey !== "no-key"; + const smallSettings = cKey !== "no-key"; - useEffect(() => { - // we need to keep track of `isPickingColor`, because we stop hovering - // when picking color, but we don't want the settings modal to close - if (smallSettings && !isSelectingColor) { - setStartExit(!isHovering); - } - }, [smallSettings, setStartExit, isHovering, isSelectingColor]); + useEffect(() => { + // we need to keep track of `isPickingColor`, because we stop hovering + // when picking color, but we don't want the settings modal to close + if (smallSettings) { + setStartExit(!isHovering && !isSelectingColor); + } + }, [smallSettings, setStartExit, isHovering, isSelectingColor]); - return ( -
{ - setIsHovering(true); - }} - onMouseLeave={() => { - setIsHovering(false); - }} - onClick={(e) => { - e.stopPropagation(); - // a little bit of a hack to close "select" dropdowns. - // by triggering a rerender - setTick(tick + 1); - }} - style={{ top: settingsOffset - 50 }} - className={cn( - "absolute left-14 top-[55px] z-20 p-2 text-sm", - "rounded border border-github-border-default bg-github-canvas-subtle", - !smallSettings && "w-[430px]", - )} - > - {!smallSettings && ( -

- Temporarily override your settings. -
- To persist changes, update your{" "} - - neovim config files - - . -

- )} -
- {(!smallSettings || cKey === "theme") && } - {(!smallSettings || cKey === "details_tags_open") && } - {(!smallSettings || cKey === "single_file") && } -
+ return ( +
{ + setIsHovering(true); + }} + onMouseLeave={() => { + setIsHovering(false); + }} + onClick={(e) => { + e.stopPropagation(); + // a little bit of a hack to close "select" dropdowns. + // by triggering a rerender + setTick(tick + 1); + }} + style={{ top: settingsOffset - 50 }} + className={cn( + "absolute top-[55px] left-14 z-20 p-2 text-sm", + "rounded border border-github-border-default bg-github-canvas-subtle", + !smallSettings && "w-[430px]", + )} + > + {!smallSettings && ( +

+ Temporarily override your settings. +
+ To persist changes, update your{" "} + + neovim config files + + . +

+ )} +
+ {(!smallSettings || cKey === "theme") && } + {(!smallSettings || cKey === "details_tags_open") && } + {(!smallSettings || cKey === "single_file") && } +
-
- {(!smallSettings || cKey === "cursor_line") && ( - - )} - {(!smallSettings || cKey === "scroll") && } -
- {!smallSettings && ( - +
+ {(!smallSettings || cKey === "cursor_line") && ( + )} -
- ); + {(!smallSettings || cKey === "scroll") && } +
+ {!smallSettings && ( + + )} +
+ ); }; diff --git a/app/web/components/explorer/settings/option.tsx b/app/web/components/explorer/settings/option.tsx index 4d255606..30f729cb 100644 --- a/app/web/components/explorer/settings/option.tsx +++ b/app/web/components/explorer/settings/option.tsx @@ -1,143 +1,143 @@ import { useContext, useEffect, type ChangeEvent } from "react"; -import { type GithubPreview } from "../../../../github-preview"; +import type { GithubPreviewConfig } from "../../../../types"; import { cn, isEqual } from "../../../utils"; import { Toggle } from "../../toggle"; import { websocketContext } from "../../websocket-provider/context"; import { Select, type SelectOption } from "./select"; type Props = { - className?: string; - name: string; - cKey: keyof GithubPreview["config"]["overrides"]; - select?: SelectOption[]; - toggle?: { - value: boolean; - onChange: (v: boolean) => void; - }; - color?: { - value: string; - onChange: (v: string) => void; - setIsSelectingColor: (b: boolean) => void; - }; - range?: { - value: number; - min: number; - max: number; - step: number; - onChange: (value: number) => void; - }; - disabled?: string | undefined; + className?: string; + name: string; + cKey: keyof GithubPreviewConfig["overrides"]; + select?: SelectOption[]; + toggle?: { + value: boolean; + onChange: (v: boolean) => void; + }; + color?: { + value: string; + onChange: (v: string) => void; + setIsSelectingColor: (b: boolean) => void; + }; + range?: { + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + }; + disabled?: string | undefined; }; export const Option = ({ - name, - cKey, - select, - color, - toggle, - range, - className, - disabled, + name, + cKey, + select, + color, + toggle, + range, + className, + disabled, }: Props) => { - const { config } = useContext(websocketContext); + const { config } = useContext(websocketContext); - useEffect(() => { - if (!color) return; + useEffect(() => { + if (!color) return; - // we need to do all this to handle color change instead of a simple - // onChange handler in the input, because we need a way to know - // when the user is picking a color (the native color picker is showing) - // and when they're done picking. we need to know this to prevent - // the settings modal from closing whilst the user is picking a color. - // - // According to mozilla: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/color#result - // the way to know this is to have a handler for "input" and a handler for "change" - // added to the color input, but the react listeners onInput and onChange - // are both triggered at the same time every time the color input value changes. + // we need to do all this to handle color change instead of a simple + // onChange handler in the input, because we need a way to know + // when the user is picking a color (the native color picker is showing) + // and when they're done picking. we need to know this to prevent + // the settings modal from closing whilst the user is picking a color. + // + // According to mozilla: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/color#result + // the way to know this is to have a handler for "input" and a handler for "change" + // added to the color input, but the react listeners onInput and onChange + // are both triggered at the same time every time the color input value changes. - const input = document.getElementById(`${cKey}-color`); - if (!input) return; + const input = document.getElementById(`${cKey}-color`); + if (!input) return; - function onInput(event: ChangeEvent) { - color?.onChange(event.target.value); - color?.setIsSelectingColor(true); - } + function onInput(event: ChangeEvent) { + color?.onChange(event.target.value); + color?.setIsSelectingColor(true); + } - function onChange(event: ChangeEvent) { - color?.onChange(event.target.value); - color?.setIsSelectingColor(false); - } + function onChange(event: ChangeEvent) { + color?.onChange(event.target.value); + color?.setIsSelectingColor(false); + } - // eslint-disable-next-line - input.addEventListener("input", onInput as any); - // eslint-disable-next-line - input.addEventListener("change", onChange as any); + // eslint-disable-next-line + input.addEventListener("input", onInput as any); + // eslint-disable-next-line + input.addEventListener("change", onChange as any); - return () => { - // eslint-disable-next-line - input.removeEventListener("input", onInput as any); - // eslint-disable-next-line - input.removeEventListener("change", onChange as any); - }; - }, [color, cKey]); + return () => { + // eslint-disable-next-line + input.removeEventListener("input", onInput as any); + // eslint-disable-next-line + input.removeEventListener("change", onChange as any); + }; + }, [color, cKey]); - if (!config) return null; + if (!config) return null; - const dotfiles = config.dotfiles[cKey]; - const override = config.overrides[cKey]; - const isOverriden = !isEqual(dotfiles, override); + const dotfiles = config.dotfiles[cKey]; + const override = config.overrides[cKey]; + const isOverriden = !isEqual(dotfiles, override); - return ( -
+ {isOverriden ? ( +
+ ) : null} +

{name}

+
+ {toggle && } + {select && + {color.value} + )} - > - {isOverriden ? ( -
- ) : null} -

{name}

-
- {toggle && } - {select && - {color.value} - - )} - {range && ( -
- { - range.onChange(Number(e.target.value)); - }} - /> - {range.value} -
- )} -
- {disabled && ( -
-
-

{disabled}

-
-
+ {range && ( +
+ { + range.onChange(Number(e.target.value)); + }} + /> + {range.value} +
)} -
- ); +
+ {disabled && ( +
+
+

{disabled}

+
+
+ )} +
+ ); }; diff --git a/app/web/components/explorer/settings/options/cursorline.tsx b/app/web/components/explorer/settings/options/cursorline.tsx index 336654a2..f8db32f4 100644 --- a/app/web/components/explorer/settings/options/cursorline.tsx +++ b/app/web/components/explorer/settings/options/cursorline.tsx @@ -3,51 +3,51 @@ import { websocketContext } from "../../../websocket-provider/context"; import { Option } from "../option"; type Props = { - setIsSelectingColor: (s: boolean) => void; + setIsSelectingColor: (s: boolean) => void; }; export const CursorlineOption = ({ setIsSelectingColor }: Props) => { - const { wsRequest, config } = useContext(websocketContext); + const { wsRequest, config } = useContext(websocketContext); - if (!config) return null; - const { overrides } = config; + if (!config) return null; + const { overrides } = config; - return ( -