Browse Source

refactor: migrated from CodeMirror to CodeJar

oldj 1 month ago
parent
commit
ce950ff5d5

+ 50 - 47
package-lock.json

@@ -26,7 +26,6 @@
         "@mantine/hooks": "^8.3.16",
         "@tabler/icons-react": "3.38.0",
         "@types/assert": "1.5.11",
-        "@types/codemirror": "5.60.15",
         "@types/lodash": "4.17.24",
         "@types/md5": "2.3.6",
         "@types/mkdirp": "2.0.0",
@@ -39,7 +38,8 @@
         "ahooks": "3.9.6",
         "chalk": "^5.6.2",
         "clsx": "2.1.1",
-        "codemirror": "5.65.17",
+        "codejar": "^4.3.0",
+        "codejar-linenumbers": "^1.0.1",
         "concurrently": "9.2.1",
         "cross-env": "10.1.0",
         "dotenv": "17.3.1",
@@ -2327,16 +2327,6 @@
         "assertion-error": "^2.0.1"
       }
     },
-    "node_modules/@types/codemirror": {
-      "version": "5.60.15",
-      "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
-      "integrity": "sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/tern": "*"
-      }
-    },
     "node_modules/@types/debug": {
       "version": "4.1.12",
       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -2478,15 +2468,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@types/tern": {
-      "version": "0.23.4",
-      "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz",
-      "integrity": "sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==",
-      "dev": true,
-      "dependencies": {
-        "@types/estree": "*"
-      }
-    },
     "node_modules/@types/uuid": {
       "version": "11.0.0",
       "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
@@ -3709,13 +3690,27 @@
         "node": ">=6"
       }
     },
-    "node_modules/codemirror": {
-      "version": "5.65.17",
-      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.17.tgz",
-      "integrity": "sha512-1zOsUx3lzAOu/gnMAZkQ9kpIHcPYOc9y1Fbm2UVk5UBPkdq380nhkelG0qUwm1f7wPvTbndu9ZYlug35EwAZRQ==",
+    "node_modules/codejar": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/codejar/-/codejar-4.3.0.tgz",
+      "integrity": "sha512-A7BlrtD2oHR4xsABs/lLvDNxiPxkx71SuyHhcilBHbPMASpQs4d1AG2XjDSAm7y40RmfXbSGbMBhllGy//CDOA==",
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/codejar-linenumbers": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/codejar-linenumbers/-/codejar-linenumbers-1.0.1.tgz",
+      "integrity": "sha512-nyJanRPlDUzWHO7HcffKpjRC7f6yU/SNQIHXhWLnZ6NuZlnFwGlo42slBHjjmK6+tcREJ7hVEwIJ3kAiYNq7lQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "codejar": "^4.1.1",
+        "prismjs": "^1.29.0"
+      },
+      "peerDependencies": {
+        "codejar": ">= 4.0.0"
+      }
+    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -6676,6 +6671,16 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/prismjs": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/proc-log": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
@@ -10040,15 +10045,6 @@
         "assertion-error": "^2.0.1"
       }
     },
-    "@types/codemirror": {
-      "version": "5.60.15",
-      "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
-      "integrity": "sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA==",
-      "dev": true,
-      "requires": {
-        "@types/tern": "*"
-      }
-    },
     "@types/debug": {
       "version": "4.1.12",
       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -10178,15 +10174,6 @@
       "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
       "dev": true
     },
-    "@types/tern": {
-      "version": "0.23.4",
-      "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz",
-      "integrity": "sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==",
-      "dev": true,
-      "requires": {
-        "@types/estree": "*"
-      }
-    },
     "@types/uuid": {
       "version": "11.0.0",
       "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
@@ -11041,12 +11028,22 @@
       "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
       "dev": true
     },
-    "codemirror": {
-      "version": "5.65.17",
-      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.17.tgz",
-      "integrity": "sha512-1zOsUx3lzAOu/gnMAZkQ9kpIHcPYOc9y1Fbm2UVk5UBPkdq380nhkelG0qUwm1f7wPvTbndu9ZYlug35EwAZRQ==",
+    "codejar": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/codejar/-/codejar-4.3.0.tgz",
+      "integrity": "sha512-A7BlrtD2oHR4xsABs/lLvDNxiPxkx71SuyHhcilBHbPMASpQs4d1AG2XjDSAm7y40RmfXbSGbMBhllGy//CDOA==",
       "dev": true
     },
+    "codejar-linenumbers": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/codejar-linenumbers/-/codejar-linenumbers-1.0.1.tgz",
+      "integrity": "sha512-nyJanRPlDUzWHO7HcffKpjRC7f6yU/SNQIHXhWLnZ6NuZlnFwGlo42slBHjjmK6+tcREJ7hVEwIJ3kAiYNq7lQ==",
+      "dev": true,
+      "requires": {
+        "codejar": "^4.1.1",
+        "prismjs": "^1.29.0"
+      }
+    },
     "color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -13137,6 +13134,12 @@
         "parse-ms": "^4.0.0"
       }
     },
+    "prismjs": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+      "dev": true
+    },
     "proc-log": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",

+ 2 - 2
package.json

@@ -47,7 +47,6 @@
     "@mantine/hooks": "^8.3.16",
     "@tabler/icons-react": "3.38.0",
     "@types/assert": "1.5.11",
-    "@types/codemirror": "5.60.15",
     "@types/lodash": "4.17.24",
     "@types/md5": "2.3.6",
     "@types/mkdirp": "2.0.0",
@@ -60,7 +59,8 @@
     "ahooks": "3.9.6",
     "chalk": "^5.6.2",
     "clsx": "2.1.1",
-    "codemirror": "5.65.17",
+    "codejar": "^4.3.0",
+    "codejar-linenumbers": "^1.0.1",
     "concurrently": "9.2.1",
     "cross-env": "10.1.0",
     "dotenv": "17.3.1",

+ 0 - 2
src/common/events.ts

@@ -11,8 +11,6 @@ export default {
   cmd_run_result: 'cmd_run_result',
   config_updated: 'config_updated',
   edit_hosts_info: 'edit_hosts_info',
-  editor_content_change: 'editor:content_change',
-  editor_gutter_click: 'editor:gutter_click',
   hosts_content_changed: 'hosts_content_changed',
   hosts_refreshed: 'hosts_refreshed',
   hosts_refreshed_by_id: 'hosts_refreshed_by_id',

+ 62 - 56
src/renderer/components/Editor/HostsEditor.module.scss

@@ -1,4 +1,4 @@
-@use "../../styles/common";
+@use '../../styles/common';
 
 .root {
   @include common.code;
@@ -11,86 +11,92 @@
   background: inherit;
 
   &.read_only {
-    :global {
-      .CodeMirror, .CodeMirror-gutters {
-        //background-color: var(--swh-editor-read-only-bg);
-      }
+    .surface {
+      background: var(--swh-editor-read-only-bg);
+      caret-color: transparent;
+      opacity: 0.8;
+      // box-shadow: inset 0 0 0 1px var(--swh-border-color-0);
+    }
 
-      .CodeMirror-cursor {
-        display: none !important
+    :global {
+      .codejar-linenumbers,
+      .codejar-linenumbers-inner-wrap {
+        background: var(--swh-editor-read-only-bg) !important;
       }
     }
   }
 
-  textarea {
-    width: 100%;
-    height: 100%;
-    border: none;
-    padding: 10px;
-    resize: none;
-    outline: none;
-    background: inherit;
-  }
-
   :global {
-    .CodeMirror {
-      font-family: common.$font-editor;
-      background: var(--swh-editor-bg-color);
-      color: var(--swh-editor-text-color);
+    .codejar-wrap {
+      height: 100%;
     }
 
-    .cm-s-default .cm-comment {
-      color: var(--swh-editor-comment);
-    }
-
-    .cm-s-default .cm-ip {
-      color: var(--swh-editor-ip);
-      font-weight: bold;
+    .codejar-linenumbers-inner-wrap {
+      background: var(--swh-editor-gutter-bg) !important;
     }
 
-    .cm-s-default .cm-error {
-      color: var(--swh-editor-error);
-    }
-
-    .cm-s-default .cm-hl {
-      background: var(--swh-editor-hl-bg);
-    }
-
-    .CodeMirror-gutters {
+    .codejar-linenumbers {
       border-right: none;
       padding-right: 6px;
       background: var(--swh-editor-gutter-bg);
+      user-select: none;
+      cursor: pointer;
     }
 
-    .CodeMirror-linenumber {
-      cursor: pointer;
+    .codejar-linenumber {
+      color: var(--swh-editor-line-number-color);
       font-size: 12px;
     }
 
-    .CodeMirror-scrollbar-filler{
-      background: var(--swh-scrollbar-filler);
+    .hl-comment {
+      color: var(--swh-editor-comment);
+    }
+
+    .hl-ip {
+      color: var(--swh-editor-ip);
+      font-weight: bold;
+    }
+
+    .hl-error {
+      color: var(--swh-editor-error);
     }
   }
 }
 
+.mount {
+  height: 100%;
+}
+
+.surface {
+  box-sizing: border-box;
+  width: 100%;
+  height: 100%;
+  padding: 8px 10px;
+  overflow: auto;
+  background: var(--swh-editor-bg-color);
+  color: var(--swh-editor-text-color);
+  font-family: common.$font-editor;
+  font-size: var(--swh-editor-font-size);
+  line-height: var(--swh-editor-line-height);
+  white-space: pre;
+  caret-color: var(--swh-editor-text-color);
+}
+
+.surface:focus {
+  outline: none;
+}
+
 :global(.theme-dark) {
   .editor {
-    :global {
-      .CodeMirror-cursor {
-        border-color: #39c;
+    .surface {
+      caret-color: #39c;
+    }
+
+    &.read_only {
+      .surface {
+        // box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
+        caret-color: transparent;
       }
     }
   }
 }
-
-.status_bar {
-  height: var(--swh-status-bar-height);
-  line-height: var(--swh-status-bar-height);
-  border-top: 1px solid var(--swh-border-color-1);
-  padding: 0 10px;
-  font-size: 12px;
-
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  color: var(--swh-font-color-weak);
-}

+ 234 - 136
src/renderer/components/Editor/HostsEditor.tsx

@@ -6,145 +6,275 @@
 import { IHostsListObject } from '@common/data'
 import events from '@common/events'
 import { IFindShowSourceParam } from '@common/types'
-import wait from '@common/utils/wait'
 import StatusBar from '@renderer/components/StatusBar'
 import { actions, agent } from '@renderer/core/agent'
 import useOnBroadcast from '@renderer/core/useOnBroadcast'
 import useHostsData from '@renderer/models/useHostsData'
 import { useDebounceFn } from 'ahooks'
 import clsx from 'clsx'
-import CodeMirror from 'codemirror'
-import 'codemirror/addon/comment/comment'
-import 'codemirror/addon/selection/mark-selection'
+import { CodeJar, type Position } from 'codejar'
+import { withLineNumbers } from 'codejar-linenumbers'
+import 'codejar-linenumbers/es/codejar-linenumbers.css'
 import { useEffect, useRef, useState } from 'react'
-import modeHosts from './cm_hl'
-import './codemirror.module.scss'
+import { highlightHosts, toggleCommentByLine, toggleCommentBySelection } from './hosts_highlight'
 import styles from './HostsEditor.module.scss'
 
-modeHosts()
-
 const HostsEditor = () => {
-  const { current_hosts, hosts_data, isReadOnly } = useHostsData()
-  const [hosts_id, setHostsId] = useState(current_hosts?.id || '0')
+  const { current_hosts, isReadOnly } = useHostsData()
+  const hosts_id = current_hosts?.id || '0'
+  const is_read_only = isReadOnly(current_hosts)
   const [content, setContent] = useState('')
-  const [is_read_only, setIsReadOnly] = useState(true)
-  const [find_params, setFindParams] = useState<IFindShowSourceParam | null>(null)
-  const ref_el = useRef<HTMLTextAreaElement>(null)
-  const ref_cm = useRef<CodeMirror.EditorFromTextArea | null>(null)
-
-  const loadContent = async (is_new = false) => {
-    let cm_editor = ref_cm.current
-    if (!cm_editor) {
-      setTimeout(loadContent, 100)
-      return
-    }
 
-    let content =
-      hosts_id === '0' ? await actions.getSystemHosts() : await actions.getHostsContent(hosts_id)
-    setContent(content)
-    cm_editor.setValue(content)
-    if (is_new) {
-      cm_editor.clearHistory()
-    }
-  }
+  const ref_mount = useRef<HTMLDivElement>(null) // outer container that hosts the CodeJar wrapper
+  const ref_editor = useRef<HTMLDivElement | null>(null) // contenteditable div managed by CodeJar
+  const ref_jar = useRef<ReturnType<typeof CodeJar> | null>(null)
+  // Refs mirror React state so that callbacks inside the CodeJar effect
+  // (which only re-runs on hosts_id change) can always read the latest values.
+  const ref_hosts_id = useRef(hosts_id)
+  const ref_is_read_only = useRef(is_read_only)
+  // Pending find: when a show_source event arrives before the target hosts is loaded,
+  // we stash the params here and apply them once loadContent finishes (with a 3s timeout).
+  const ref_pending_find = useRef<IFindShowSourceParam | null>(null)
+  const ref_pending_find_timer = useRef<number | null>(null)
 
   useEffect(() => {
-    // console.log(current_hosts)
-    setHostsId(current_hosts?.id || '0')
-    let is_readonly = isReadOnly(current_hosts)
-    setIsReadOnly(is_readonly)
-    if (ref_cm.current) {
-      ref_cm.current.setOption('readOnly', is_readonly)
-    }
-  }, [current_hosts])
+    ref_hosts_id.current = hosts_id
+  }, [hosts_id])
 
   useEffect(() => {
-    console.log(hosts_id)
-    loadContent(true).catch((e) => console.error(e))
-  }, [hosts_id])
+    ref_is_read_only.current = is_read_only
+  }, [is_read_only])
+
+  const clearPendingFind = () => {
+    if (ref_pending_find_timer.current) {
+      window.clearTimeout(ref_pending_find_timer.current)
+      ref_pending_find_timer.current = null
+    }
+    ref_pending_find.current = null
+  }
+
+  useEffect(() => clearPendingFind, [])
 
   const { run: toSave } = useDebounceFn(
-    (id: string, content: string) => {
+    (id: string, nextContent: string) => {
       actions
-        .setHostsContent(id, content)
+        .setHostsContent(id, nextContent)
         .then(() => agent.broadcast(events.hosts_content_changed, id))
         .catch((e) => console.error(e))
     },
     { wait: 1000 },
   )
 
-  const onChange = (content: string) => {
-    setContent(content)
-    toSave(hosts_id, content)
+  /** Toggle contenteditable between 'plaintext-only' and 'false' (Chromium/Electron only). */
+  const setEditorReadOnly = (readOnly: boolean) => {
+    const editor = ref_editor.current
+    if (!editor) return
+
+    editor.setAttribute('contenteditable', readOnly ? 'false' : 'plaintext-only')
+    editor.setAttribute('aria-readonly', readOnly ? 'true' : 'false')
   }
 
-  const toggleComment = () => {
-    let cm_editor = ref_cm.current
-    if (is_read_only || !cm_editor) return
-    cm_editor.toggleComment()
-
-    // 光标移到下一行
-    let cursor = cm_editor.getCursor()
-    cursor.line += 1
-    cm_editor.setCursor(cursor)
+  /** Scroll the current selection/cursor into view after programmatic focus changes. */
+  const scrollSelectionIntoView = () => {
+    const editor = ref_editor.current
+    if (!editor) return
+
+    const selection = window.getSelection()
+    if (!selection || selection.rangeCount === 0) return
+
+    const range = selection.getRangeAt(0)
+    const startNode = range.startContainer
+    const target =
+      startNode.nodeType === Node.TEXT_NODE
+        ? startNode.parentElement
+        : (startNode as Element | null)
+
+    ;(target ?? editor).scrollIntoView({
+      block: 'nearest',
+      inline: 'nearest',
+    })
   }
 
-  const onGutterClick = (n: number) => {
-    let cm_editor = ref_cm.current
-    if (is_read_only || !cm_editor) return
+  /** Restore a character-offset selection in the editor (used by find/show-source). */
+  const setSelection = (params: IFindShowSourceParam) => {
+    const jar = ref_jar.current
+    const editor = ref_editor.current
+    if (!jar || !editor) return
+
+    const editorContent = jar.toString()
+    const start = Math.max(0, Math.min(params.start, editorContent.length))
+    const end = Math.max(0, Math.min(params.end, editorContent.length))
+    jar.restore({
+      start,
+      end,
+      dir: '->',
+    })
+    editor.focus()
+    window.requestAnimationFrame(scrollSelectionIntoView)
+  }
+
+  /** Fetch and display the hosts content. Applies any pending find selection after loading. */
+  const loadContent = async (targetHostsId = hosts_id) => {
+    const jar = ref_jar.current
+    if (!jar) return
+
+    const nextContent =
+      targetHostsId === '0'
+        ? await actions.getSystemHosts()
+        : await actions.getHostsContent(targetHostsId)
 
-    let info = cm_editor.lineInfo(n)
-    let line = info.text
-    if (/^\s*$/.test(line)) return
+    if (ref_hosts_id.current !== targetHostsId) return
+
+    setContent(nextContent)
+    jar.updateCode(nextContent, false)
+
+    const pendingFind = ref_pending_find.current
+    if (pendingFind && pendingFind.item_id === targetHostsId) {
+      setSelection(pendingFind)
+      clearPendingFind()
+    }
+  }
+
+  const getCurrentSelection = (): Position => {
+    const jar = ref_jar.current
+    const editor = ref_editor.current
+    const fallbackOffset = jar?.toString().length ?? 0
+    if (!jar || !editor) {
+      return {
+        start: fallbackOffset,
+        end: fallbackOffset,
+        dir: '->',
+      }
+    }
 
-    let new_line: string
-    if (/^#/.test(line)) {
-      new_line = line.replace(/^#\s*/, '')
-    } else {
-      new_line = '# ' + line
+    try {
+      return jar.save()
+    } catch {
+      return {
+        start: fallbackOffset,
+        end: fallbackOffset,
+        dir: '->',
+      }
     }
+  }
 
-    cm_editor
-      .getDoc()
-      .replaceRange(new_line, { line: info.line, ch: 0 }, { line: info.line, ch: line.length })
+  const onChange = (nextContent: string) => {
+    setContent(nextContent)
+    toSave(hosts_id, nextContent)
   }
 
-  useEffect(() => {
-    if (!ref_el.current) return
+  /** Push a programmatic edit into CodeJar: update content, restore selection, and record undo history. */
+  const applyEditorChange = (nextContent: string, nextSelection: Position) => {
+    const jar = ref_jar.current
+    const editor = ref_editor.current
+    if (!jar || !editor) return
+
+    editor.focus()
+    jar.recordHistory()
+    jar.updateCode(nextContent, false)
+    jar.restore(nextSelection)
+    editor.focus()
+    jar.recordHistory()
+    onChange(nextContent)
+  }
 
-    let cm = CodeMirror.fromTextArea(ref_el.current, {
-      lineNumbers: true,
-      readOnly: is_read_only,
-      mode: 'hosts',
-    })
-    ref_cm.current = cm
+  const toggleComment = () => {
+    if (ref_is_read_only.current) return
+
+    const jar = ref_jar.current
+    if (!jar) return
 
-    cm.setSize('100%', '100%')
+    const selection = getCurrentSelection()
+    const next = toggleCommentBySelection(jar.toString(), selection.start, selection.end, true)
+    if (!next.changed) return
 
-    cm.on('change', (editor) => {
-      let value = editor.getDoc().getValue()
-      agent.broadcast(events.editor_content_change, value)
+    applyEditorChange(next.content, {
+      start: next.selectionStart,
+      end: next.selectionEnd,
+      dir: '->',
     })
+  }
+
+  /** Handle a click on the line-number gutter to toggle comment on that line. */
+  const onGutterClick = (lineIndex: number) => {
+    if (ref_is_read_only.current) return
+
+    const jar = ref_jar.current
+    if (!jar) return
+
+    const selection = getCurrentSelection()
+    const next = toggleCommentByLine(jar.toString(), lineIndex, selection.start, selection.end)
+    if (!next.changed) return
 
-    cm.on('gutterClick', (cm, n) => {
-      agent.broadcast(events.editor_gutter_click, n)
+    applyEditorChange(next.content, {
+      start: next.selectionStart,
+      end: next.selectionEnd,
+      dir: '->',
     })
-  }, [])
+  }
 
   useEffect(() => {
-    if (find_params && find_params.item_id === hosts_id) {
-      setSelection(find_params, true).catch((e) => console.error(e))
+    const mount = ref_mount.current
+    if (!mount) return
+
+    mount.replaceChildren()
+
+    const editor = document.createElement('div')
+    editor.className = styles.surface
+    editor.tabIndex = 0
+    mount.appendChild(editor)
+
+    const jar = CodeJar(
+      editor,
+      withLineNumbers(highlightHosts, {
+        width: '25px',
+        backgroundColor: 'var(--swh-editor-gutter-bg)',
+        color: 'var(--swh-editor-line-number-color)',
+      }),
+    )
+    ref_editor.current = editor
+    ref_jar.current = jar
+    setEditorReadOnly(is_read_only)
+
+    const onEditorUpdate = (nextContent: string) => {
+      onChange(nextContent)
     }
-  }, [hosts_id, find_params])
 
-  useOnBroadcast(
-    events.editor_content_change,
-    (new_content: string) => {
-      if (new_content === content) return
-      onChange(new_content)
-    },
-    [hosts_id, content],
-  )
+    // Detect clicks on the line-number gutter and convert the click Y position
+    // into a zero-based line index, accounting for scroll offset of the wrapper.
+    const onMountClick = (event: MouseEvent) => {
+      const target = event.target as HTMLElement | null
+      const gutter = target?.closest('.codejar-linenumbers')
+      if (!gutter) return
+
+      const lineHeight = parseFloat(window.getComputedStyle(editor).lineHeight) || 24
+      const scrollContainer = gutter.closest('.codejar-wrap') ?? editor
+      const relativeY =
+        event.clientY - gutter.getBoundingClientRect().top + scrollContainer.scrollTop
+      const lineCount = Math.max(1, jar.toString().split('\n').length)
+      const lineIndex = Math.max(0, Math.min(lineCount - 1, Math.floor(relativeY / lineHeight)))
+
+      event.preventDefault()
+      onGutterClick(lineIndex)
+    }
+
+    jar.onUpdate(onEditorUpdate)
+    jar.updateCode('', false)
+    mount.addEventListener('click', onMountClick)
+    loadContent(hosts_id).catch((e) => console.error(e))
+
+    return () => {
+      mount.removeEventListener('click', onMountClick)
+      jar.destroy()
+      mount.replaceChildren()
+      ref_jar.current = null
+      ref_editor.current = null
+    }
+  }, [hosts_id])
+
+  useEffect(() => {
+    setEditorReadOnly(is_read_only)
+  }, [is_read_only])
 
   useOnBroadcast(
     events.hosts_refreshed,
@@ -161,7 +291,7 @@ const HostsEditor = () => {
       if (hosts_id !== '0' && hosts_id !== id) return
       loadContent().catch((e) => console.error(e))
     },
-    [hosts_id, hosts_data],
+    [hosts_id],
   )
 
   useOnBroadcast(
@@ -184,47 +314,20 @@ const HostsEditor = () => {
     [hosts_id],
   )
 
-  useOnBroadcast(events.editor_gutter_click, onGutterClick, [is_read_only])
-  useOnBroadcast(events.toggle_comment, toggleComment, [is_read_only])
-
-  const setSelection = async (params: IFindShowSourceParam, repeat = false) => {
-    let cm_editor = ref_cm.current
-    if (!cm_editor) return
-    let doc = cm_editor.getDoc()
-
-    doc.setSelection(
-      {
-        line: params.line - 1,
-        ch: params.line_pos,
-      },
-      {
-        line: params.end_line - 1,
-        ch: params.end_line_pos,
-      },
-    )
-
-    // console.log(doc.getSelection())
-    await wait(200)
-    if (!doc.getSelection()) {
-      await setSelection(params)
-    }
-    cm_editor.focus()
-  }
+  useOnBroadcast(events.toggle_comment, toggleComment, [hosts_id])
 
   useOnBroadcast(
     events.show_source,
-    async (params: IFindShowSourceParam) => {
-      if (!ref_cm.current) return
-
-      if (params.item_id !== hosts_id) {
-        setFindParams(params)
-        setTimeout(() => {
-          setFindParams(null)
-        }, 3000)
+    (params: IFindShowSourceParam) => {
+      if (params.item_id !== hosts_id || !ref_jar.current) {
+        clearPendingFind()
+        ref_pending_find.current = params
+        ref_pending_find_timer.current = window.setTimeout(clearPendingFind, 3000)
         return
       }
 
-      setSelection(params).catch((e) => console.error(e))
+      clearPendingFind()
+      setSelection(params)
     },
     [hosts_id],
   )
@@ -232,12 +335,7 @@ const HostsEditor = () => {
   return (
     <div className={styles.root}>
       <div className={clsx(styles.editor, is_read_only && styles.read_only)}>
-        <textarea
-          ref={ref_el}
-          defaultValue={content}
-          // onChange={e => onChange(e.target.value)}
-          // disabled={is_read_only}
-        />
+        <div ref={ref_mount} className={styles.mount} />
       </div>
 
       <StatusBar

+ 0 - 47
src/renderer/components/Editor/cm_hl.ts

@@ -1,47 +0,0 @@
-// custom mode
-
-import CodeMirror from 'codemirror'
-
-export default function () {
-  CodeMirror.defineMode('hosts', function () {
-    function tokenBase(stream: CodeMirror.StringStream) {
-      if (stream.eatSpace()) return null
-
-      let sol = stream.sol()
-      let ch = stream.next()
-
-      let s = stream.string
-
-      if (ch === '#') {
-        stream.skipToEnd()
-        return 'comment'
-      }
-      if (!s.match(/^\s*([\d.]+|[\da-f:.%lo]+)\s+\w/i)) {
-        return 'error'
-      }
-
-      if (sol && ch && ch.match(/[\w.:%]/)) {
-        stream.eatWhile(/[\w.:%]/)
-        return 'ip'
-      }
-
-      return null
-    }
-
-    function tokenize(stream: CodeMirror.StringStream, state: any) {
-      return (state.tokens[0] || tokenBase)(stream, state)
-    }
-
-    return {
-      startState: function () {
-        return { tokens: [] }
-      },
-      token: function (stream, state) {
-        return tokenize(stream, state)
-      },
-      lineComment: '#',
-    }
-  })
-
-  //CodeMirror.defineMIME('text/x-hosts', 'hosts');
-}

+ 0 - 546
src/renderer/components/Editor/codemirror.module.scss

@@ -1,546 +0,0 @@
-:global {
-  /* BASICS */
-
-  .CodeMirror {
-    /* Set height, width, borders, and global font properties here */
-    font-family: monospace;
-    height: 300px;
-    color: black;
-    direction: ltr;
-  }
-
-  /* PADDING */
-
-  .CodeMirror-lines {
-    padding: 4px 0; /* Vertical padding around content */
-  }
-
-  .CodeMirror pre.CodeMirror-line,
-  .CodeMirror pre.CodeMirror-line-like {
-    padding: 0 4px; /* Horizontal padding of content */
-  }
-
-  .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
-    background-color: white; /* The little square between H and V scrollbars */
-  }
-
-  /* GUTTER */
-
-  .CodeMirror-gutters {
-    border-right: 1px solid #ddd;
-    background-color: #f7f7f7;
-    white-space: nowrap;
-  }
-
-  .CodeMirror-linenumbers {
-  }
-
-  .CodeMirror-linenumber {
-    padding: 0 3px 0 5px;
-    min-width: 20px;
-    text-align: right;
-    color: #999;
-    white-space: nowrap;
-  }
-
-  .CodeMirror-guttermarker {
-    color: black;
-  }
-
-  .CodeMirror-guttermarker-subtle {
-    color: #999;
-  }
-
-  /* CURSOR */
-
-  .CodeMirror-cursor {
-    border-left: 1px solid black;
-    border-right: none;
-    width: 0;
-  }
-
-  /* Shown when moving in bi-directional text */
-
-  .CodeMirror div.CodeMirror-secondarycursor {
-    border-left: 1px solid silver;
-  }
-
-  .cm-fat-cursor .CodeMirror-cursor {
-    width: auto;
-    border: 0 !important;
-    background: #7e7;
-  }
-
-  .cm-fat-cursor div.CodeMirror-cursors {
-    z-index: 1;
-  }
-
-  .cm-fat-cursor-mark {
-    background-color: rgba(20, 255, 20, 0.5);
-    -webkit-animation: blink 1.06s steps(1) infinite;
-    -moz-animation: blink 1.06s steps(1) infinite;
-    animation: blink 1.06s steps(1) infinite;
-  }
-
-  .cm-animate-fat-cursor {
-    width: auto;
-    border: 0;
-    -webkit-animation: blink 1.06s steps(1) infinite;
-    -moz-animation: blink 1.06s steps(1) infinite;
-    animation: blink 1.06s steps(1) infinite;
-    background-color: #7e7;
-  }
-
-  @-moz-keyframes blink {
-    0% {
-    }
-    50% {
-      background-color: transparent;
-    }
-    100% {
-    }
-  }
-  @-webkit-keyframes blink {
-    0% {
-    }
-    50% {
-      background-color: transparent;
-    }
-    100% {
-    }
-  }
-  @keyframes blink {
-    0% {
-    }
-    50% {
-      background-color: transparent;
-    }
-    100% {
-    }
-  }
-
-  /* Can style cursor different in overwrite (non-insert) mode */
-
-  .CodeMirror-overwrite .CodeMirror-cursor {
-  }
-
-  .cm-tab {
-    display: inline-block;
-    text-decoration: inherit;
-  }
-
-  .CodeMirror-rulers {
-    position: absolute;
-    left: 0;
-    right: 0;
-    top: -50px;
-    bottom: 0;
-    overflow: hidden;
-  }
-
-  .CodeMirror-ruler {
-    border-left: 1px solid #ccc;
-    top: 0;
-    bottom: 0;
-    position: absolute;
-  }
-
-  /* DEFAULT THEME */
-
-  .cm-s-default .cm-header {
-    color: blue;
-  }
-
-  .cm-s-default .cm-quote {
-    color: #090;
-  }
-
-  .cm-negative {
-    color: #d44;
-  }
-
-  .cm-positive {
-    color: #292;
-  }
-
-  .cm-header, .cm-strong {
-    font-weight: bold;
-  }
-
-  .cm-em {
-    font-style: italic;
-  }
-
-  .cm-link {
-    text-decoration: underline;
-  }
-
-  .cm-strikethrough {
-    text-decoration: line-through;
-  }
-
-  .cm-s-default .cm-keyword {
-    color: #708;
-  }
-
-  .cm-s-default .cm-atom {
-    color: #219;
-  }
-
-  .cm-s-default .cm-number {
-    color: #164;
-  }
-
-  .cm-s-default .cm-def {
-    color: #00f;
-  }
-
-  .cm-s-default .cm-variable,
-  .cm-s-default .cm-punctuation,
-  .cm-s-default .cm-property,
-  .cm-s-default .cm-operator {
-  }
-
-  .cm-s-default .cm-variable-2 {
-    color: #05a;
-  }
-
-  .cm-s-default .cm-variable-3, .cm-s-default .cm-type {
-    color: #085;
-  }
-
-  .cm-s-default .cm-comment {
-    color: #a50;
-  }
-
-  .cm-s-default .cm-string {
-    color: #a11;
-  }
-
-  .cm-s-default .cm-string-2 {
-    color: #f50;
-  }
-
-  .cm-s-default .cm-meta {
-    color: #555;
-  }
-
-  .cm-s-default .cm-qualifier {
-    color: #555;
-  }
-
-  .cm-s-default .cm-builtin {
-    color: #30a;
-  }
-
-  .cm-s-default .cm-bracket {
-    color: #997;
-  }
-
-  .cm-s-default .cm-tag {
-    color: #170;
-  }
-
-  .cm-s-default .cm-attribute {
-    color: #00c;
-  }
-
-  .cm-s-default .cm-hr {
-    color: #999;
-  }
-
-  .cm-s-default .cm-link {
-    color: #00c;
-  }
-
-  .cm-s-default .cm-error {
-    color: #f00;
-  }
-
-  .cm-invalidchar {
-    color: #f00;
-  }
-
-  .CodeMirror-composing {
-    border-bottom: 2px solid;
-  }
-
-  /* Default styles for common addons */
-
-  div.CodeMirror span.CodeMirror-matchingbracket {
-    color: #0b0;
-  }
-
-  div.CodeMirror span.CodeMirror-nonmatchingbracket {
-    color: #a22;
-  }
-
-  .CodeMirror-matchingtag {
-    background: rgba(255, 150, 0, .3);
-  }
-
-  .CodeMirror-activeline-background {
-    background: #e8f2ff;
-  }
-
-  /* STOP */
-
-  /* The rest of this file contains styles related to the mechanics of
-     the editor. You probably shouldn't touch them. */
-
-  .CodeMirror {
-    position: relative;
-    overflow: hidden;
-    background: white;
-  }
-
-  .CodeMirror-scroll {
-    overflow: scroll !important; /* Things will break if this is overridden */
-    /* 50px is the magic margin used to hide the element's real scrollbars */
-    /* See overflow: hidden in .CodeMirror */
-    margin-bottom: -50px;
-    margin-right: -50px;
-    padding-bottom: 50px;
-    height: 100%;
-    outline: none; /* Prevent dragging from highlighting the element */
-    position: relative;
-  }
-
-  .CodeMirror-sizer {
-    position: relative;
-    border-right: 50px solid transparent;
-  }
-
-  /* The fake, visible scrollbars. Used to force redraw during scrolling
-     before actual scrolling happens, thus preventing shaking and
-     flickering artifacts. */
-
-  .CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
-    position: absolute;
-    z-index: 6;
-    display: none;
-    outline: none;
-  }
-
-  .CodeMirror-vscrollbar {
-    right: 0;
-    top: 0;
-    overflow-x: hidden;
-    overflow-y: scroll;
-  }
-
-  .CodeMirror-hscrollbar {
-    bottom: 0;
-    left: 0;
-    overflow-y: hidden;
-    overflow-x: scroll;
-  }
-
-  .CodeMirror-scrollbar-filler {
-    right: 0;
-    bottom: 0;
-  }
-
-  .CodeMirror-gutter-filler {
-    left: 0;
-    bottom: 0;
-  }
-
-  .CodeMirror-gutters {
-    position: absolute;
-    left: 0;
-    top: 0;
-    min-height: 100%;
-    z-index: 3;
-  }
-
-  .CodeMirror-gutter {
-    white-space: normal;
-    height: 100%;
-    display: inline-block;
-    vertical-align: top;
-    margin-bottom: -50px;
-  }
-
-  .CodeMirror-gutter-wrapper {
-    position: absolute;
-    z-index: 4;
-    background: none !important;
-    border: none !important;
-  }
-
-  .CodeMirror-gutter-background {
-    position: absolute;
-    top: 0;
-    bottom: 0;
-    z-index: 4;
-  }
-
-  .CodeMirror-gutter-elt {
-    position: absolute;
-    cursor: default;
-    z-index: 4;
-  }
-
-  .CodeMirror-gutter-wrapper ::selection {
-    background-color: transparent
-  }
-
-  .CodeMirror-gutter-wrapper ::-moz-selection {
-    background-color: transparent
-  }
-
-  .CodeMirror-lines {
-    cursor: text;
-    min-height: 1px; /* prevents collapsing before first draw */
-  }
-
-  .CodeMirror pre.CodeMirror-line,
-  .CodeMirror pre.CodeMirror-line-like {
-    /* Reset some styles that the rest of the page might have set */
-    -moz-border-radius: 0;
-    -webkit-border-radius: 0;
-    border-radius: 0;
-    border-width: 0;
-    background: transparent;
-    font-family: inherit;
-    font-size: inherit;
-    margin: 0;
-    white-space: pre;
-    word-wrap: normal;
-    line-height: inherit;
-    color: inherit;
-    z-index: 2;
-    position: relative;
-    overflow: visible;
-    -webkit-tap-highlight-color: transparent;
-    -webkit-font-variant-ligatures: contextual;
-    font-variant-ligatures: contextual;
-  }
-
-  .CodeMirror-wrap pre.CodeMirror-line,
-  .CodeMirror-wrap pre.CodeMirror-line-like {
-    word-wrap: break-word;
-    white-space: pre-wrap;
-    word-break: normal;
-  }
-
-  .CodeMirror-linebackground {
-    position: absolute;
-    left: 0;
-    right: 0;
-    top: 0;
-    bottom: 0;
-    z-index: 0;
-  }
-
-  .CodeMirror-linewidget {
-    position: relative;
-    z-index: 2;
-    padding: 0.1px; /* Force widget margins to stay inside of the container */
-  }
-
-  .CodeMirror-widget {
-  }
-
-  .CodeMirror-rtl pre {
-    direction: rtl;
-  }
-
-  .CodeMirror-code {
-    outline: none;
-  }
-
-  /* Force content-box sizing for the elements where we expect it */
-
-  .CodeMirror-scroll,
-  .CodeMirror-sizer,
-  .CodeMirror-gutter,
-  .CodeMirror-gutters,
-  .CodeMirror-linenumber {
-    -moz-box-sizing: content-box;
-    box-sizing: content-box;
-  }
-
-  .CodeMirror-measure {
-    position: absolute;
-    width: 100%;
-    height: 0;
-    overflow: hidden;
-    visibility: hidden;
-  }
-
-  .CodeMirror-cursor {
-    position: absolute;
-    pointer-events: none;
-  }
-
-  .CodeMirror-measure pre {
-    position: static;
-  }
-
-  div.CodeMirror-cursors {
-    visibility: hidden;
-    position: relative;
-    z-index: 3;
-  }
-
-  div.CodeMirror-dragcursors {
-    visibility: visible;
-  }
-
-  .CodeMirror-focused div.CodeMirror-cursors {
-    visibility: visible;
-  }
-
-  .CodeMirror-selected {
-    background: #d9d9d9;
-  }
-
-  .CodeMirror-focused .CodeMirror-selected {
-    background: #d7d4f0;
-  }
-
-  .CodeMirror-crosshair {
-    cursor: crosshair;
-  }
-
-  .CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection {
-    background: #d7d4f0;
-  }
-
-  .CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection {
-    background: #d7d4f0;
-  }
-
-  .cm-searching {
-    background-color: #ffa;
-    background-color: rgba(255, 255, 0, .4);
-  }
-
-  /* Used to force a border model for a node */
-
-  .cm-force-border {
-    padding-right: .1px;
-  }
-
-  @media print {
-    /* Hide the cursor when printing */
-    .CodeMirror div.CodeMirror-cursors {
-      visibility: hidden;
-    }
-  }
-
-  /* See issue #2901 */
-
-  .cm-tab-wrap-hack:after {
-    content: '';
-  }
-
-  /* Help users use markselection to safely style text background */
-
-  span.CodeMirror-selectedtext {
-    background: none;
-  }
-
-}

+ 86 - 0
src/renderer/components/Editor/hosts_highlight.test.ts

@@ -0,0 +1,86 @@
+/**
+ * Tests for hosts file syntax highlighting and comment toggling.
+ * Covers HTML rendering of comment / IP / error lines,
+ * single-line and multi-line comment toggle with cursor adjustment,
+ * and gutter (line-index) based toggling.
+ */
+
+import {
+  highlightHostsLine,
+  highlightHostsText,
+  toggleCommentByLine,
+  toggleCommentBySelection,
+} from './hosts_highlight'
+import { describe, expect, it } from 'vitest'
+
+describe('hosts_highlight', () => {
+  it('highlights comment lines', () => {
+    expect(highlightHostsLine('  # localhost')).toBe(
+      '<span class="hl-comment">  # localhost</span>',
+    )
+  })
+
+  it('highlights valid hosts lines with leading whitespace', () => {
+    expect(highlightHostsLine('  127.0.0.1 localhost')).toBe(
+      '  <span class="hl-ip">127.0.0.1</span> localhost',
+    )
+  })
+
+  it('marks invalid lines as errors and escapes html', () => {
+    expect(highlightHostsLine('foo <bar>')).toBe(
+      '<span class="hl-error">foo &lt;bar&gt;</span>',
+    )
+  })
+
+  it('preserves multiline output including trailing newline', () => {
+    expect(highlightHostsText('127.0.0.1 localhost\n# ok\n')).toBe(
+      '<span class="hl-ip">127.0.0.1</span> localhost\n<span class="hl-comment"># ok</span>\n',
+    )
+  })
+
+  it('toggles the current line and moves the cursor to the next line', () => {
+    const code = '127.0.0.1 localhost\nfoo'
+    const result = toggleCommentBySelection(code, 0, 0, true)
+
+    expect(result.content).toBe('# 127.0.0.1 localhost\nfoo')
+    expect(result.selectionStart).toBe('# 127.0.0.1 localhost\n'.length)
+    expect(result.selectionEnd).toBe('# 127.0.0.1 localhost\n'.length)
+  })
+
+  it('toggles every line touched by a selection', () => {
+    const code = '127.0.0.1 localhost\nfoo'
+    const result = toggleCommentBySelection(code, 0, code.length)
+
+    expect(result.content).toBe('# 127.0.0.1 localhost\n# foo')
+    expect(result.selectionStart).toBe(2)
+    expect(result.selectionEnd).toBe(code.length + 4)
+  })
+
+  it('keeps blank lines as no-op', () => {
+    const code = 'foo\n\nbar'
+    const result = toggleCommentBySelection(code, 4, 4, true)
+
+    expect(result.changed).toBe(false)
+    expect(result.content).toBe(code)
+    expect(result.selectionStart).toBe(4)
+    expect(result.selectionEnd).toBe(4)
+  })
+
+  it('adjusts selection offsets when uncommenting indented lines', () => {
+    const code = '  # foo\nbar'
+    const result = toggleCommentBySelection(code, 4, 7)
+
+    expect(result.content).toBe('  foo\nbar')
+    expect(result.selectionStart).toBe(2)
+    expect(result.selectionEnd).toBe(5)
+  })
+
+  it('toggles a single line by gutter index', () => {
+    const code = 'foo\nbar'
+    const result = toggleCommentByLine(code, 1, 0, 0)
+
+    expect(result.content).toBe('foo\n# bar')
+    expect(result.selectionStart).toBe(0)
+    expect(result.selectionEnd).toBe(0)
+  })
+})

+ 311 - 0
src/renderer/components/Editor/hosts_highlight.ts

@@ -0,0 +1,311 @@
+/**
+ * Hosts file syntax highlighting and comment toggling for CodeJar.
+ *
+ * Highlighting: converts plain-text hosts content into HTML with
+ * `hl-comment`, `hl-ip`, and `hl-error` spans for styling.
+ *
+ * Comment toggling: adds/removes `# ` prefixes while preserving
+ * cursor/selection positions via offset-based transforms.
+ */
+
+import type { Position } from 'codejar'
+
+/** Matches a valid hosts entry: optional whitespace, an IPv4/IPv6 address, then a hostname. */
+const HOSTS_LINE_RE = /^\s*([\d.]+|[\da-f:.%lo]+)\s+\w/i
+/** Captures the leading indent and `# ` prefix of a comment line for removal. */
+const COMMENT_LINE_RE = /^(\s*)#\s*/
+
+/** A single line with its byte offsets within the full document. */
+interface LineInfo {
+  start: number
+  end: number
+  text: string
+}
+
+/**
+ * Transform records describe how a single toggle operation shifted characters.
+ * They are collected per-line and then applied to map the original cursor/selection
+ * offsets to their new positions in the modified text.
+ */
+interface InsertTransform {
+  type: 'insert'
+  at: number
+  length: number
+}
+
+interface RemoveTransform {
+  type: 'remove'
+  start: number
+  end: number
+}
+
+type Transform = InsertTransform | RemoveTransform
+
+export interface CommentToggleResult {
+  content: string
+  selectionStart: number
+  selectionEnd: number
+  changed: boolean
+}
+
+interface ToggleLineResult {
+  nextText: string
+  changed: boolean
+  transform?: Transform
+}
+
+export function escapeHtml(text: string): string {
+  return text
+    .replaceAll('&', '&amp;')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;')
+    .replaceAll('"', '&quot;')
+    .replaceAll("'", '&#039;')
+}
+
+export function isHostsCommentLine(line: string): boolean {
+  return /^\s*#/.test(line)
+}
+
+export function isValidHostsLine(line: string): boolean {
+  return HOSTS_LINE_RE.test(line)
+}
+
+export function highlightHostsLine(line: string): string {
+  if (!line) return ''
+
+  if (isHostsCommentLine(line)) {
+    return `<span class="hl-comment">${escapeHtml(line)}</span>`
+  }
+
+  if (!isValidHostsLine(line)) {
+    return `<span class="hl-error">${escapeHtml(line)}</span>`
+  }
+
+  const match = line.match(/^(\s*)([\w.:%]+)/)
+  if (!match) {
+    return escapeHtml(line)
+  }
+
+  const [, indent, ip] = match
+  const rest = line.slice(indent.length + ip.length)
+  return `${escapeHtml(indent)}<span class="hl-ip">${escapeHtml(ip)}</span>${escapeHtml(rest)}`
+}
+
+export function highlightHostsText(code: string): string {
+  return code
+    .split('\n')
+    .map((line) => highlightHostsLine(line))
+    .join('\n')
+}
+
+/** CodeJar highlight callback — replaces the editor's innerHTML with syntax-highlighted HTML. */
+export function highlightHosts(editor: HTMLElement, _pos?: Position): void {
+  editor.innerHTML = highlightHostsText(editor.textContent || '')
+}
+
+function getLines(code: string): LineInfo[] {
+  const parts = code.split('\n')
+  let start = 0
+
+  return parts.map((text) => {
+    const line = {
+      start,
+      end: start + text.length,
+      text,
+    }
+    start += text.length + 1
+    return line
+  })
+}
+
+function getLineIndexAtOffset(lines: LineInfo[], offset: number): number {
+  if (lines.length === 0) return 0
+
+  for (let i = lines.length - 1; i >= 0; i -= 1) {
+    if (offset >= lines[i].start) {
+      return i
+    }
+  }
+
+  return 0
+}
+
+function toggleLine(line: string, lineStart: number): ToggleLineResult {
+  if (/^\s*$/.test(line)) {
+    return {
+      nextText: line,
+      changed: false,
+    }
+  }
+
+  const commentMatch = line.match(COMMENT_LINE_RE)
+  if (commentMatch) {
+    const indent = commentMatch[1]
+    return {
+      nextText: line.replace(COMMENT_LINE_RE, '$1'),
+      changed: true,
+      transform: {
+        type: 'remove',
+        start: lineStart + indent.length,
+        end: lineStart + commentMatch[0].length,
+      },
+    }
+  }
+
+  return {
+    nextText: `# ${line}`,
+    changed: true,
+    transform: {
+      type: 'insert',
+      at: lineStart,
+      length: 2,
+    },
+  }
+}
+
+/** Map an original document offset through a series of insert/remove transforms. */
+function mapOffset(offset: number, transforms: Transform[]): number {
+  let mapped = offset
+
+  for (const transform of transforms) {
+    if (transform.type === 'insert') {
+      if (offset >= transform.at) {
+        mapped += transform.length
+      }
+      continue
+    }
+
+    if (offset <= transform.start) continue
+
+    if (offset < transform.end) {
+      mapped -= offset - transform.start
+      continue
+    }
+
+    mapped -= transform.end - transform.start
+  }
+
+  return mapped
+}
+
+function getLineStartOffsets(lines: string[]): number[] {
+  const starts: number[] = []
+  let start = 0
+
+  for (const line of lines) {
+    starts.push(start)
+    start += line.length + 1
+  }
+
+  return starts
+}
+
+function getSelectionRange(selectionStart: number, selectionEnd: number) {
+  return {
+    start: Math.min(selectionStart, selectionEnd),
+    end: Math.max(selectionStart, selectionEnd),
+  }
+}
+
+/**
+ * Core toggle implementation: comment/uncomment lines in [startLineIndex, endLineIndex],
+ * returning the updated text and adjusted selection offsets.
+ * When `moveToNextLine` is true and the selection is collapsed (cursor), the cursor
+ * is moved to the start of the next line after toggling (mimics IDE behavior).
+ */
+function toggleCommentLines(
+  code: string,
+  selectionStart: number,
+  selectionEnd: number,
+  startLineIndex: number,
+  endLineIndex: number,
+  moveToNextLine: boolean,
+): CommentToggleResult {
+  const lines = getLines(code)
+  const nextLines = lines.map((line) => line.text)
+  const transforms: Transform[] = []
+  let changed = false
+
+  for (let i = startLineIndex; i <= endLineIndex; i += 1) {
+    const line = lines[i]
+    const result = toggleLine(line.text, line.start)
+    nextLines[i] = result.nextText
+    changed ||= result.changed
+    if (result.transform) {
+      transforms.push(result.transform)
+    }
+  }
+
+  if (!changed) {
+    return {
+      content: code,
+      selectionStart,
+      selectionEnd,
+      changed: false,
+    }
+  }
+
+  const nextContent = nextLines.join('\n')
+  if (moveToNextLine && selectionStart === selectionEnd) {
+    const nextStarts = getLineStartOffsets(nextLines)
+    const nextLineIndex = startLineIndex + 1
+    const nextOffset = nextStarts[nextLineIndex] ?? nextContent.length
+    return {
+      content: nextContent,
+      selectionStart: nextOffset,
+      selectionEnd: nextOffset,
+      changed: true,
+    }
+  }
+
+  return {
+    content: nextContent,
+    selectionStart: mapOffset(selectionStart, transforms),
+    selectionEnd: mapOffset(selectionEnd, transforms),
+    changed: true,
+  }
+}
+
+/** Toggle comment on all lines touched by the current selection range. */
+export function toggleCommentBySelection(
+  code: string,
+  selectionStart: number,
+  selectionEnd: number,
+  moveToNextLine = false,
+): CommentToggleResult {
+  const lines = getLines(code)
+  const { start, end } = getSelectionRange(selectionStart, selectionEnd)
+  const startLineIndex = getLineIndexAtOffset(lines, start)
+  const endLineIndex =
+    start === end ? startLineIndex : getLineIndexAtOffset(lines, Math.max(start, end - 1))
+
+  return toggleCommentLines(
+    code,
+    selectionStart,
+    selectionEnd,
+    startLineIndex,
+    endLineIndex,
+    moveToNextLine,
+  )
+}
+
+/** Toggle comment on a single line identified by its zero-based index (used for gutter clicks). */
+export function toggleCommentByLine(
+  code: string,
+  lineIndex: number,
+  selectionStart: number,
+  selectionEnd: number,
+): CommentToggleResult {
+  const lines = getLines(code)
+  if (lineIndex < 0 || lineIndex >= lines.length) {
+    return {
+      content: code,
+      selectionStart,
+      selectionEnd,
+      changed: false,
+    }
+  }
+
+  return toggleCommentLines(code, selectionStart, selectionEnd, lineIndex, lineIndex, false)
+}

+ 1 - 1
src/renderer/components/TopBar/index.module.scss

@@ -44,7 +44,7 @@
 }
 
 .read_only {
-  color: var(--swh-font-color-weak);
+  // color: var(--swh-font-color-weak);
   background-color: var(--swh-top-bar-read-only-bg);
   border-radius: var(--swh-border-radius);
   font-size: 10px;

+ 1 - 1
src/renderer/styles/themes/dark.scss

@@ -49,7 +49,7 @@
   --swh-editor-error-color: #900;
   --swh-editor-line-number-color: #999;
   --swh-editor-line-number-bg: #fff;
-  --swh-editor-read-only-bg: #262b33;
+  --swh-editor-read-only-bg: #2d333c;
   --swh-editor-font-size: 1em;
   --swh-editor-line-height: 1.8em;
 

+ 1 - 1
vitest.config.mts

@@ -6,7 +6,7 @@ export default defineConfig({
   test: {
     environment: 'node',
     fileParallelism: false,
-    include: [ 'test/**/*.test.ts' ],
+    include: [ 'test/**/*.test.ts', 'src/**/*.test.ts' ],
     setupFiles: [ './test/setup.ts' ],
   },
 })