From 2955a326abfad96fc73b53da18b184227d1b83a8 Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Mon, 15 Jun 2026 21:40:18 +0900 Subject: [PATCH] fix: restore editor focus after eager extension activation Eager activation (coderm.extensions.eagerActivation, PR #193/#205) activates vscode-neovim early, but other startup work running afterwards occasionally strands focus on (no pane focused), so vscode-neovim stops responding to normal-mode keys until the user alt+tabs away and back. Add StartupFocusGuardController that opens a short window after each eager extension finishes activating (onDidChangeExtensionsStatus + activationTimes) and refocuses the active editor group only when focus truly lands on nothing (getActiveElement() is body/html/null). Intentional focus moves (e.g. clicking the terminal) are respected. New setting: coderm.startup.focusGuard.enabled (default: true). Co-Authored-By: Claude --- README.ja.md | 1 + README.md | 1 + .../coderm/browser/coderm.contribution.ts | 1 + .../coderm/browser/startupFocusGuard.ts | 253 ++++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 src/vs/workbench/contrib/coderm/browser/startupFocusGuard.ts diff --git a/README.ja.md b/README.ja.md index 6491f60020f..e3dfa45d092 100644 --- a/README.ja.md +++ b/README.ja.md @@ -65,6 +65,7 @@ VS Codeのフォーク。本家にはないUI/UX改善を追加した(してい | `coderm.workbench.editor.autoMaximizeOnFocus` | `boolean` | `true` | 最小ペインにフォーカス時の自動最大化を制御 | | `coderm.workbench.editor.preventNewGroupOnFocus` | `boolean` | `false` | フォーカス時に新しいエディタグループの作成を抑制 | | `coderm.extensions.eagerActivation` | `string[]` | `["asvetliakov.vscode-neovim", "vscodevim.vim"]` | 起動時に即座にアクティベートする拡張機能IDのリスト | +| `coderm.startup.focusGuard.enabled` | `boolean` | `true` | eager activation 後フォーカスが迷子になったらエディタを1回復元 | | `coderm.workbench.editor.resizeIncrement` | `number` | `60` | ペインリサイズの増分(px) | | `coderm.terminal.horizontalPadding` | `number` | `20` | ターミナルの水平パディング(px, 0–100) | | `coderm.quickOpen.includeTerminals` | `boolean` | `true` | Quick Openにターミナルエディタを含める | diff --git a/README.md b/README.md index f6d6b1252f6..62536d081fd 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Settings unique to Coderm that are not available in upstream VS Code. | `coderm.workbench.editor.autoMaximizeOnFocus` | `boolean` | `true` | Control auto-maximize when focusing smallest pane | | `coderm.workbench.editor.preventNewGroupOnFocus` | `boolean` | `false` | Prevent creating new editor group on focus | | `coderm.extensions.eagerActivation` | `string[]` | `["asvetliakov.vscode-neovim", "vscodevim.vim"]` | Extensions to activate eagerly on startup | +| `coderm.startup.focusGuard.enabled` | `boolean` | `true` | Restore editor focus once if eager activation strands it | | `coderm.workbench.editor.resizeIncrement` | `number` | `60` | Pane resize increment (px) | | `coderm.terminal.horizontalPadding` | `number` | `20` | Terminal horizontal padding (px, 0–100) | | `coderm.quickOpen.includeTerminals` | `boolean` | `true` | Include terminal editors in Quick Open | diff --git a/src/vs/workbench/contrib/coderm/browser/coderm.contribution.ts b/src/vs/workbench/contrib/coderm/browser/coderm.contribution.ts index b051005ba1a..d8a089281c5 100644 --- a/src/vs/workbench/contrib/coderm/browser/coderm.contribution.ts +++ b/src/vs/workbench/contrib/coderm/browser/coderm.contribution.ts @@ -22,6 +22,7 @@ import './preventNewGroupOnFocus.js'; import './quickOpenIncludeTerminals.js'; import './resizePaneActions.js'; import './remoteSSHGuard.js'; +import './startupFocusGuard.js'; import './terminalHorizontalPadding.js'; import './terminalKillFocusRestore.js'; import './updateDownloadProgress.js'; diff --git a/src/vs/workbench/contrib/coderm/browser/startupFocusGuard.ts b/src/vs/workbench/contrib/coderm/browser/startupFocusGuard.ts new file mode 100644 index 00000000000..b7c57b5e368 --- /dev/null +++ b/src/vs/workbench/contrib/coderm/browser/startupFocusGuard.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../base/browser/window.js'; +import { getActiveElement, addDisposableListener } from '../../../../base/browser/dom.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; + +// #region Configuration + +/** + * Configuration key for the startup focus guard. + * + * Eager extension activation (see `coderm.extensions.eagerActivation`) starts + * extensions like vscode-neovim early, which is the whole point — but the rest + * of startup work that runs afterwards occasionally leaves focus stranded on + * `` (no pane focused), so the user has to alt+tab away and back to get + * the editor responsive again. When enabled (default), this contribution + * refocuses the active editor group once, during a short window after the eager + * extensions finish activating, but only when focus is truly lost — never when + * the user deliberately moved it elsewhere. + */ +export const CodermStartupFocusGuardSetting = 'coderm.startup.focusGuard.enabled'; + +/** + * How long after eager extensions activate we keep watching for focus loss. + * + * Why: The observed regression is a one-time focus theft that happens right + * after activation, while the rest of startup work drains. A few seconds is + * plenty and keeps the guard from interfering with later intentional focus + * moves. Not exposed as a setting to keep the surface area minimal + * (CLAUDE.md: "do not add flexibility that was not requested"). + */ +const FOCUS_GUARD_WINDOW_MS = 3000; + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'coderm.startupFocusGuard', + order: 106, + title: localize('codermConfigurationTitle', 'Coderm'), + type: 'object', + properties: { + [CodermStartupFocusGuardSetting]: { + type: 'boolean', + default: true, + scope: ConfigurationScope.APPLICATION, + description: localize('coderm.startup.focusGuard.enabled', + 'Restores editor focus once after startup when eager extension activation (e.g. vscode-neovim) causes focus to drift away from the editor. Watches for a short window after eager extensions finish activating and refocuses the active editor group only when focus lands on nothing (i.e. no pane has focus). Intentional focus moves (e.g. clicking the terminal) are respected. Default: enabled.'), + }, + }, +}); + +// #endregion + +// #region Controller + +/** + * Restores editor focus after startup when eager extension activation strands + * focus (no pane focused). + * + * Why: Eager activation (see `coderm.extensions.eagerActivation`) activates + * vscode-neovim early — the original goal of PR #193/#205. The side effect is + * that other startup work running afterwards can steal focus and leave it on + * `` / nothing, so vscode-neovim (which keys off `editorTextFocus`) + * silently stops responding to normal-mode keys until the user alt+tabs away + * and back. This guard opens a short window right after each eager extension + * activates and refocuses the active editor group only when focus is truly + * lost. + * + * Constraint: We deliberately do NOT refocus when `getActiveElement()` points + * at a real pane (terminal, sidebar, panel, command palette). That is an + * intentional focus move and must be respected — only the "focus landed on + * nothing" case is treated as the regression we want to fix. This is what lets + * us avoid the complexity of mousedown/keyboard intent tracking: distinguishing + * "stranded" from "deliberately moved" reduces to a single `getActiveElement()` + * check. + */ +export class StartupFocusGuardController extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.coderm.startupFocusGuard'; + + /** Holds the focusout listener and window timer; cleared when the window closes. */ + private readonly _windowStore = new DisposableStore(); + + private _windowActive = false; + + constructor( + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IExtensionService private readonly _extensionService: IExtensionService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(this._windowStore); + + // Why: Honor the setting being toggled off at runtime — close any active + // guard window immediately so it stops interfering. + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(CodermStartupFocusGuardSetting) && !this._isEnabled()) { + this._closeWindow(); + } + })); + + // Why: Register the status listener unconditionally (rather than returning + // early when disabled) so toggling the setting ON at runtime can still + // observe a future activation. The per-event _isEnabled() check below + // honors the OFF state, matching terminalKillFocusRestore's pattern. + this._register(this._extensionService.onDidChangeExtensionsStatus(ids => { + if (!this._isEnabled()) { + return; + } + this._onExtensionsStatusChanged(ids); + })); + } + + public override dispose(): void { + // Why: In-flight _onFocusOut setTimeout callbacks check this flag; setting + // it false here makes a post-dispose fire a safe no-op. + this._windowActive = false; + super.dispose(); + } + + private _isEnabled(): boolean { + return this._configurationService.getValue(CodermStartupFocusGuardSetting) ?? true; + } + + /** Reads the shared eager-activation list directly to avoid coupling to eagerExtensions.ts. */ + private _readEagerExtensionIds(): string[] { + const ids = this._configurationService.getValue('coderm.extensions.eagerActivation'); + return Array.isArray(ids) ? ids : []; + } + + /** + * Opens the guard window once any eager extension reports it has finished + * activating. The event fires for several reasons (release barrier, runtime + * errors, activation completion); only `activationTimes` being set indicates + * actual completion. + */ + private _onExtensionsStatusChanged(ids: ExtensionIdentifier[]): void { + if (this._windowActive) { + return; + } + const eagerIds = this._readEagerExtensionIds(); + if (eagerIds.length === 0) { + return; + } + const status = this._extensionService.getExtensionsStatus(); + const eagerJustActivated = ids.some(id => { + if (!eagerIds.some(eagerId => ExtensionIdentifier.equals(eagerId, id))) { + return false; + } + // Why `id.value` (raw) and not `toKey` (lowercased): getExtensionsStatus() + // keys its result by `extension.identifier.value`, the raw id string — + // a lowercased lookup would miss mixed-case ids like "GitHub.Copilot". + const entry = status[id.value]; + return entry?.activationTimes !== undefined; + }); + if (eagerJustActivated) { + this._openWindow(); + } + } + + private _openWindow(): void { + this._windowActive = true; + + // focusout bubbles (unlike blur), so we can catch focus leaving the editor + // at the document level. useCapture so we run before per-pane handlers. + this._windowStore.add(addDisposableListener(mainWindow.document, 'focusout', () => { + this._onFocusOut(); + }, true)); + + this._windowStore.add(disposableTimeout(() => { + this._closeWindow(); + }, FOCUS_GUARD_WINDOW_MS, this._windowStore)); + } + + /** + * On focus leaving an element, check whether focus has truly been lost. + * + * Why the one-tick deferral: at `focusout` time the new active element is + * not settled yet, so `getActiveElement()` would still report the element + * that lost focus. Deferring lets the browser commit the new focus target. + * This mirrors `editorGroupView`'s FOCUS_OUT handler, which uses the same + * `setTimeout(..., 0)` trick. + */ + private _onFocusOut(): void { + // Why: Plain setTimeout (not disposableTimeout added to _windowStore) so + // _restoreFocus -> _closeWindow -> _windowStore.clear() never clears the + // store from within a callback the store owns. The _windowActive flag + // (set false in dispose() and _closeWindow()) makes a post-dispose or + // post-close fire a safe no-op. + setTimeout(() => { + if (!this._windowActive) { + return; + } + if (this._isStrandedFocus(getActiveElement())) { + this._restoreFocus(); + } + }, 0); + } + + /** + * Returns true only when focus has landed on "nothing" — the regression + * signature. A real pane (terminal, sidebar, panel, command palette) is an + * intentional focus move and must be left alone. + */ + private _isStrandedFocus(active: Element | null): boolean { + if (active === null) { + return true; + } + return active === mainWindow.document.body || active === mainWindow.document.documentElement; + } + + private _restoreFocus(): void { + this._editorGroupsService.activeGroup.focus(); + this._logService.info('[Coderm] startup focus guard: restored editor focus after eager activation'); + this._closeWindow(); + } + + private _closeWindow(): void { + this._windowActive = false; + this._windowStore.clear(); + } +} + +// #endregion + +// #region Registration + +// Why: AfterRestored (not earlier) because eager extension activation runs +// after `_initialize()` completes (see `_activateEagerExtensions()` in +// abstractExtensionService.ts), which aligns with LifecyclePhase.Restored. +// An earlier phase would register the `onDidChangeExtensionsStatus` listener +// before the eager extensions finish activating, but the guard's design +// assumes it can observe the activation completion event itself — registering +// too late would miss that event and never open the guard window. +registerWorkbenchContribution2( + StartupFocusGuardController.ID, + StartupFocusGuardController, + WorkbenchPhase.AfterRestored +); + +// #endregion