From a134a595c0f01f626ce915add4e46eefb503ef96 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 09:00:40 -0500 Subject: [PATCH 1/2] Fix(editor): typing in a just-indented empty task lands after its checkbox (#1012) Vim `o` on a task line, then Tab, then typing: the caret showed after the new sub-task's `- [ ] `, but the text went in before it, as ` abc- [ ] `. Enter then Tab with Vim off did the same, in Live Preview and in Source mode. Only an empty task that gets indented; a task with text, plain bullets and numbered lists were fine. Since #952 (f2512e32, first in 2.71.0) the list indent is drawn as one single-space cell per column so nested lists line up in a proportional font. On an empty task the caret sits after the marker's trailing space, itself a lone space text node. When Tab indents that line, CodeMirror's redraw reuses that very node for one of the new indent cells. getSelection() then reports the line's end, which CodeMirror takes as already right and leaves alone, but Chromium keeps its own caret in the moved node, so the next keystroke lands inside the indent. CodeMirror's guard for this Chrome behavior only notices a node that was rewritten or removed, not one moved intact (6.43.15 does not change that). The list indent plugin now notes the caret's text node before a document change is drawn and, when that node is still in the editor but the reported caret has left it, collapses the selection onto the position it already reports, which brings Chromium's caret back. It skips IME composition and costs nothing while the caret stays in its node. Every editor that draws list cells gets it through the plugin: the main editor, the pinned reference pane and the link popover. It does not change how the cells are drawn; the alignment from #952 stays. --- .../src/lib/cm-markdown-list-indent.test.ts | 83 ++++++++++++++++++- .../src/lib/cm-markdown-list-indent.ts | 46 +++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/packages/app-core/src/lib/cm-markdown-list-indent.test.ts b/packages/app-core/src/lib/cm-markdown-list-indent.test.ts index 5c44a9ca6..41e0a94e0 100644 --- a/packages/app-core/src/lib/cm-markdown-list-indent.test.ts +++ b/packages/app-core/src/lib/cm-markdown-list-indent.test.ts @@ -1,10 +1,12 @@ // @vitest-environment jsdom +import { indentMore } from '@codemirror/commands' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' -import { forceParsing } from '@codemirror/language' +import { forceParsing, HighlightStyle, indentUnit, syntaxHighlighting } from '@codemirror/language' import { Compartment, EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' -import { describe, expect, it } from 'vitest' +import { tags } from '@lezer/highlight' +import { afterEach, describe, expect, it, vi } from 'vitest' import { listGuideStyle, listIndentPadCh, @@ -128,3 +130,80 @@ describe('markdownListIndentPlugin', () => { parent.remove() }) }) + +describe('the caret after Tab nests an empty task (#1012)', () => { + // The editor's own token classes: the redraw that moves the caret's node + // depends on the list spans they produce around the marker. + const tokens = HighlightStyle.define([ + { tag: tags.list, class: 'tok-list' }, + { tag: tags.meta, class: 'tok-meta' }, + { tag: tags.atom, class: 'tok-atom' } + ]) + let view: EditorView | null = null + + function mountFocused(doc: string): EditorView { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + const parent = document.createElement('div') + document.body.append(parent) + view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor: doc.length }, + extensions: [ + markdown({ base: markdownLanguage }), + syntaxHighlighting(tokens), + indentUnit.of(' '), + markdownListIndentPlugin + ] + }) + }) + forceParsing(view, doc.length, 5000) + view.focus() + return view + } + + afterEach(() => { + view?.destroy() + view = null + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + it('sets the DOM caret again where the page reports it, so typing lands after the marker', () => { + const editor = mountFocused('- [ ] parent\n- [ ] ') + const selection = document.getSelection()! + const caretNode = selection.focusNode! + expect(caretNode.textContent).toBe(' ') + const collapse = vi.spyOn(selection, 'collapse') + + indentMore(editor) + + expect(editor.state.doc.toString()).toBe('- [ ] parent\n - [ ] ') + // The redraw reused the caret's node for an indent cell, and the page now + // reports the line's end, which CodeMirror accepts without setting it. + expect(caretNode.isConnected).toBe(true) + expect((caretNode.parentNode as Element).closest('.cm-list-prefix-cell')).not.toBeNull() + expect(selection.focusNode).not.toBe(caretNode) + expect(editor.posAtDOM(selection.focusNode!, selection.focusOffset)).toBe(editor.state.doc.length) + // So the plugin sets it, on that same reported place. + expect(collapse).toHaveBeenCalledTimes(1) + expect(collapse).toHaveBeenCalledWith(selection.focusNode, selection.focusOffset) + }) + + it('leaves the DOM caret alone when an edit keeps it in its node', () => { + // A caret on the parent line while a nested task changes below it, the + // way most edits leave the caret: no extra selection write per change. + const editor = mountFocused('- [ ] parent\n - [ ] x') + editor.dispatch({ selection: { anchor: '- [ ] parent'.length } }) + const selection = document.getSelection()! + const caretNode = selection.focusNode + const collapse = vi.spyOn(selection, 'collapse') + + editor.dispatch({ changes: { from: editor.state.doc.length, insert: 'y' }, userEvent: 'input.type' }) + + expect(editor.state.doc.toString()).toBe('- [ ] parent\n - [ ] xy') + expect(selection.focusNode).toBe(caretNode) + expect(collapse).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/lib/cm-markdown-list-indent.ts b/packages/app-core/src/lib/cm-markdown-list-indent.ts index 535234d78..bd3096d8e 100644 --- a/packages/app-core/src/lib/cm-markdown-list-indent.ts +++ b/packages/app-core/src/lib/cm-markdown-list-indent.ts @@ -210,15 +210,49 @@ function computeDecorations(view: EditorView): DecorationSet { return builder.finish() } +/** The text node holding a collapsed caret in this editor, if there is one. */ +function caretTextNode(view: EditorView): Node | null { + const selection = view.dom.ownerDocument.getSelection() + const node = selection?.isCollapsed ? selection.focusNode : null + return node?.nodeType === Node.TEXT_NODE && view.contentDOM.contains(node) ? node : null +} + +/** + * Puts Chromium's caret back where the page says it is, after a redraw moved + * the text node it was in (#1012). + * + * The prefix cells give an indent one single-space text node per column. On + * an empty task the caret sits after the `- [ ] ` marker's trailing space, + * itself a lone space, so when Tab indents that line, CodeMirror's redraw + * reuses the caret's node for one of the new indent cells. getSelection() + * then reports the line's end, which CodeMirror takes as already right and + * leaves alone, but Chromium keeps its own caret in the moved node, and the + * next keystroke lands before the marker. CodeMirror's guard for this Chrome + * behavior only notices a node that was rewritten or removed, not one moved + * intact. Collapsing the selection onto the position it already reports + * brings Chromium's caret back to it. + */ +function resyncMovedCaret(view: EditorView, before: Node): void { + if (view.composing || !view.contentDOM.contains(before)) return + const selection = view.dom.ownerDocument.getSelection() + if (!selection?.isCollapsed || !selection.focusNode || selection.focusNode === before) return + if (!view.contentDOM.contains(selection.focusNode)) return + selection.collapse(selection.focusNode, selection.focusOffset) +} + export const markdownListIndentPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet + /** The caret's text node before this update's redraw (see resyncMovedCaret). */ + caretBefore: Node | null = null constructor(view: EditorView) { this.decorations = computeDecorations(view) } update(update: ViewUpdate): void { + // Plugins update before CodeMirror redraws, so the DOM is still the old one. + this.caretBefore = update.docChanged ? caretTextNode(update.view) : null if ( update.docChanged || update.viewportChanged || @@ -232,6 +266,16 @@ export const markdownListIndentPlugin = ViewPlugin.fromClass( } }, { - decorations: (plugin) => plugin.decorations + decorations: (plugin) => plugin.decorations, + // Update listeners run after the redraw and after CodeMirror has placed + // the DOM selection. + provide: (plugin) => + EditorView.updateListener.of((update) => { + const value = update.view.plugin(plugin) + const before = value?.caretBefore + if (!value || !before) return + value.caretBefore = null + resyncMovedCaret(update.view, before) + }) } ) From cefd86d977663f013300fc0cb24657d09113c8a5 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 09:00:40 -0500 Subject: [PATCH 2/2] Fix(remote): a server whose certificate a private CA signed connects on macOS (#1009) Connecting to a self-hosted server over HTTPS with a certificate from a private CA (step-ca here) failed with "Could not reach the server: fetch failed", although macOS trusted that CA and a browser opened the server. NODE_USE_SYSTEM_CA=1 cured it, but only when set before the app starts. The window trusts the system's certificate store through Chromium, but every request from the main process, the MCP server and the bundled CLI goes through Node's TLS (fetch, and the `ws` change feed), and Node trusts only the list bundled into it. On this Mac the system store holds roots Node does not ship: gs.apple.com, signed by Apple Root CA, fails with SELF_SIGNED_CERT_IN_CHAIN on Node's list and answers once the system's certificates are added, which is the same failure and the same cure. system-trust.ts adds the system's certificates to Node's default list with tls.setDefaultCACertificates (Node 24.18 in Electron 41). Reading the store costs 20 to 30 ms on macOS, so it is read once and off the boot path: before the first request a remote client makes (RemoteServerClient for the app, remoteJsonRequest for the MCP server and the CLI), and two seconds after the window shows for Cloud, update checks and link previews. Trust is only added: a store that cannot be read, or a Node without these calls, keeps Node's own list, so nothing that connected before stops connecting. --- apps/desktop/src/main/index.ts | 4 ++ apps/desktop/src/main/remote/connection.ts | 3 ++ apps/desktop/src/main/remote/server-client.ts | 3 ++ apps/desktop/src/main/system-trust.test.ts | 44 ++++++++++++++++ apps/desktop/src/main/system-trust.ts | 51 +++++++++++++++++++ 5 files changed, 105 insertions(+) create mode 100644 apps/desktop/src/main/system-trust.test.ts create mode 100644 apps/desktop/src/main/system-trust.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4abffcd35..c40333365 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -346,6 +346,7 @@ import { resolveMarkdownOpenTarget, } from "./file-open"; import { isStandaloneLink, resolveStandaloneLink } from "./standalone-links"; +import { trustSystemCertificates } from "./system-trust"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const nodeRequire = createRequire(import.meta.url); @@ -2053,6 +2054,9 @@ async function createWindow( ); if (restoredState?.isMaximized) win.maximize(); win.show(); + // Cloud, updates and link previews trust the system's certificates too, + // read once the window is up rather than on the boot path (#1009). + setTimeout(trustSystemCertificates, 2000); }); win.webContents.on("did-start-loading", () => { readyWindowIds.delete(win.id); diff --git a/apps/desktop/src/main/remote/connection.ts b/apps/desktop/src/main/remote/connection.ts index fdf9fc21c..dc8888af4 100644 --- a/apps/desktop/src/main/remote/connection.ts +++ b/apps/desktop/src/main/remote/connection.ts @@ -10,6 +10,8 @@ * module instead and stays fetch-only. */ +import { trustSystemCertificates } from '../system-trust' + export interface RemoteRequestOptions { baseUrl: string authToken?: string | null @@ -91,6 +93,7 @@ export async function remoteJsonRequest( headers.set('Content-Type', 'application/json') } + trustSystemCertificates() let response: Response try { response = await fetch(`${options.baseUrl}${path}`, { diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts index 0dc33f0ed..b6b9d3dd9 100644 --- a/apps/desktop/src/main/remote/server-client.ts +++ b/apps/desktop/src/main/remote/server-client.ts @@ -27,6 +27,7 @@ import { normalizeBaseUrl, requestErrorMessage } from './connection' +import { trustSystemCertificates } from '../system-trust' export interface RemoteServerClientOptions { baseUrl: string @@ -76,6 +77,8 @@ export class RemoteServerClient { private readonly onStalePath: ((path: string) => void) | null constructor(options: RemoteServerClientOptions) { + // Every request and the change feed go through Node's TLS (system-trust.ts). + trustSystemCertificates() this.baseUrl = normalizeBaseUrl(options.baseUrl) this.authToken = options.authToken?.trim() || null this.onStalePath = options.onStalePath ?? null diff --git a/apps/desktop/src/main/system-trust.test.ts b/apps/desktop/src/main/system-trust.test.ts new file mode 100644 index 000000000..f65cce769 --- /dev/null +++ b/apps/desktop/src/main/system-trust.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { addSystemCertificates, type CertificateStore } from './system-trust' + +function store(defaults: string[], system: string[] | Error): CertificateStore & { + set: ReturnType +} { + const set = vi.fn() + return { + getCACertificates: (type) => { + if (type === 'default') return defaults + if (system instanceof Error) throw system + return system + }, + setDefaultCACertificates: set, + set + } +} + +describe('addSystemCertificates (#1009)', () => { + it("adds the system's certificates to Node's own, once each", () => { + const certificates = store(['node-a', 'node-b'], ['node-b', 'private-ca']) + expect(addSystemCertificates(certificates)).toBe(true) + expect(certificates.set).toHaveBeenCalledWith(['node-a', 'node-b', 'private-ca']) + }) + + it('leaves the default list alone when the system store is empty', () => { + const certificates = store(['node-a'], []) + expect(addSystemCertificates(certificates)).toBe(false) + expect(certificates.set).not.toHaveBeenCalled() + }) + + it("keeps Node's list when the system store cannot be read", () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const certificates = store(['node-a'], new Error('keychain locked')) + expect(addSystemCertificates(certificates)).toBe(false) + expect(certificates.set).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) + + it('does nothing on a Node without the calls', () => { + expect(addSystemCertificates({} as CertificateStore)).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/system-trust.ts b/apps/desktop/src/main/system-trust.ts new file mode 100644 index 000000000..9510d3288 --- /dev/null +++ b/apps/desktop/src/main/system-trust.ts @@ -0,0 +1,51 @@ +import tls from 'node:tls' + +/** + * Trust the operating system's certificate store in this process, as Chromium + * already does for the window. + * + * Node's TLS, which every request from the main process, the MCP server and + * the bundled CLI goes through (fetch, the `ws` change feed), trusts only the + * list bundled into Node. A self-hosted server whose certificate comes from a + * private CA the system trusts (step-ca, a company root, a proxy that inspects + * TLS) answered "fetch failed" while a browser on the same Mac opened it fine + * (#1009). NODE_USE_SYSTEM_CA=1 cured it, but only when set before the process + * starts, which nobody launching from the Dock can do. + * + * Reading the system store costs 20 to 30 ms on macOS, so it is read once and + * never on the boot path: before the first request to a server, and a moment + * after the window shows for everything else. Trust is only ever added: a + * store that cannot be read, or a Node without these calls, keeps Node's list. + */ + +export interface CertificateStore { + getCACertificates(type: 'default' | 'system'): string[] + setDefaultCACertificates(certificates: string[]): void +} + +/** Make the default list Node's own plus the system's. False when nothing changed. */ +export function addSystemCertificates(store: CertificateStore): boolean { + if ( + typeof store.getCACertificates !== 'function' || + typeof store.setDefaultCACertificates !== 'function' + ) { + return false + } + try { + const system = store.getCACertificates('system') + if (system.length === 0) return false + store.setDefaultCACertificates([...new Set([...store.getCACertificates('default'), ...system])]) + return true + } catch (error) { + console.warn("[tls] the system certificate store could not be read; keeping Node's list", error) + return false + } +} + +let systemTrustRead = false + +export function trustSystemCertificates(): void { + if (systemTrustRead) return + systemTrustRead = true + addSystemCertificates(tls) +}