From 04cd30b197a159d86e0ebb7c865da8f15458fd95 Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 13:15:12 +0200 Subject: [PATCH 01/12] fix: address low-severity code quality findings - Remove unused formatFileSize/formatPermissions imports (findFile.ts) - Replace magic number literals with named constants across extension.ts, menu.ts, openEditors.ts, projectFile.ts, fuzzy.ts, mru.ts - Fix broken column-0 indentation in menu.ts and showBindings.ts - Add console.warn to silent catch blocks in findFile, recentProjects, fuzzy, and extension.ts - Drop unnecessary async/Promise from moveSelection (3 panels) and revealEditorLine (fuzzy.ts) - Change recentProjects.prepareShow return type to void with interface note - Replace Math.random() nonces with crypto.randomUUID() in helpers.ts and menu.ts --- src/buffers/openEditors.ts | 4 +++- src/extension.ts | 13 +++++++++---- src/panel/helpers.ts | 4 ++-- src/search/findFile.ts | 7 ++++--- src/search/fuzzy.ts | 30 +++++++++++++++++------------- src/search/projectFile.ts | 8 +++++--- src/search/recentProjects.ts | 12 +++++++----- src/whichkey/menu.ts | 15 +++++++++------ src/whichkey/showBindings.ts | 2 +- src/window/mru.ts | 11 ++++++++--- 10 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/buffers/openEditors.ts b/src/buffers/openEditors.ts index cef09f1..2372b5c 100644 --- a/src/buffers/openEditors.ts +++ b/src/buffers/openEditors.ts @@ -3,6 +3,8 @@ import * as vscode from 'vscode'; import { createNonce, formatFileSize, fuzzyMatch, tildeCollapse } from '../panel/helpers'; import { focusEditorGroup } from '../window/mru'; +const REFRESH_DEBOUNCE_MS = 50; + // --------------------------------------------------------------------------- // Open editor models // --------------------------------------------------------------------------- @@ -586,7 +588,7 @@ export class DoomOpenEditorsPanel { this.refreshTimer = setTimeout(() => { this.refreshTimer = undefined; void this.refreshItems(); - }, 50); + }, REFRESH_DEBOUNCE_MS); } /** Rebuilds the flat item list from all tab groups, deduplicating by key and skipping hidden tabs. */ diff --git a/src/extension.ts b/src/extension.ts index 2ade893..d0b5cac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -42,6 +42,9 @@ const PREVIOUS_WORKSPACE_TARGET_KEY = 'doom.previousWorkspaceTarget'; const PENDING_OPEN_FILE_KEY = 'doom.pendingOpenFile'; /** Set before an intentional project switch so the activation IIFE skips the dashboard. */ const SKIP_DASHBOARD_KEY = 'doom.skipDashboardOnActivation'; + +const TERMINAL_ESCAPE_TIMEOUT_MS = 2000; +const DASHBOARD_REFRESH_DEBOUNCE_MS = 50; const KEEP_EXISTING_BINDING_ACTION = 'Keep Existing'; const OVERWRITE_WITH_DOOM_ACTION = 'Overwrite with Doom'; const KEEP_ALL_EXISTING_BINDINGS_ACTION = 'Keep All Existing'; @@ -570,7 +573,8 @@ function readKeybindingsJson(keybindingsPath: string): Array | undefined; let terminalEscapeTimer: ReturnType | undefined; // Debounced refresh so rapid config changes (e.g. bulk settings apply) don't re-render on every key. - const scheduleDashboardRefresh = (delayMs = 50) => { + const scheduleDashboardRefresh = (delayMs = DASHBOARD_REFRESH_DEBOUNCE_MS) => { if (!dashboard.getCurrentMode()) { return; } @@ -1097,7 +1101,7 @@ export function activate(context: vscode.ExtensionContext) { terminalEscapeTimer = setTimeout(() => { terminalEscapeTimer = undefined; void vscode.commands.executeCommand('setContext', 'doom.terminalEscapeMode', false); - }, 2000); + }, TERMINAL_ESCAPE_TIMEOUT_MS); } ); @@ -1436,8 +1440,9 @@ export function activate(context: vscode.ExtensionContext) { const fileUri = vscode.Uri.parse(pendingFile, true); const document = await vscode.workspace.openTextDocument(fileUri); await vscode.window.showTextDocument(document, { preview: false, preserveFocus: false }); - } catch { + } catch (err) { // File may have moved or be outside the workspace — silently skip. + console.warn('[Doom] pendingOpenFile failed to open:', err); } await context.globalState.update(LAST_SEEN_VERSION_KEY, getExtensionMetadata(context).version); diff --git a/src/panel/helpers.ts b/src/panel/helpers.ts index 2a3615d..128f4fa 100644 --- a/src/panel/helpers.ts +++ b/src/panel/helpers.ts @@ -68,9 +68,9 @@ export interface FuzzyMatch { score: number; } -/** Generates a random 10-char alphanumeric nonce for CSP script-src directives. */ +/** Generates a cryptographically random nonce for CSP script-src directives. */ export function createNonce(): string { - return Math.random().toString(36).slice(2, 12); + return crypto.randomUUID(); } /** Substring match. Returns undefined if query is not found as a contiguous run in text. */ diff --git a/src/search/findFile.ts b/src/search/findFile.ts index 0f0368d..db34518 100644 --- a/src/search/findFile.ts +++ b/src/search/findFile.ts @@ -1,7 +1,7 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; -import { createFilePickerHtml, createNonce, formatFileSize, formatPermissions, formatRelativeTime, normalizePath, tildeCollapse, tildeExpand } from '../panel/helpers'; +import { createFilePickerHtml, createNonce, formatRelativeTime, normalizePath, tildeCollapse, tildeExpand } from '../panel/helpers'; import { SelectionHistory } from './selectionHistory'; // --------------------------------------------------------------------------- @@ -129,7 +129,7 @@ export class DoomFindFilePanel { this.ready = false; } - async moveSelection(delta: number): Promise { + moveSelection(delta: number): void { if (!this.view?.visible || this.filteredItems.length === 0) { return; } @@ -250,7 +250,8 @@ export class DoomFindFilePanel { 'doom-workspace.readDirectory', this.makeUri(this.currentDir).toString() ); - } catch { + } catch (err) { + console.warn('[DoomFindFile] readDirectory failed:', err); this.allItems = []; return; } diff --git a/src/search/fuzzy.ts b/src/search/fuzzy.ts index 9398f33..0593e0b 100644 --- a/src/search/fuzzy.ts +++ b/src/search/fuzzy.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; import { createNonce, fuzzyMatch } from '../panel/helpers'; +const MAX_RESULTS = 200; + // --------------------------------------------------------------------------- // Search models // --------------------------------------------------------------------------- @@ -114,7 +116,7 @@ export class DoomFuzzySearchPanel { } /** Moves the active result by `delta` rows and live-previews the line in editor mode. No-op at list boundaries. */ - async moveSelection(delta: number): Promise { + moveSelection(delta: number): void { if (!this.view?.visible || this.filteredItems.length === 0) { return; } @@ -130,7 +132,7 @@ export class DoomFuzzySearchPanel { this.activeIndex = nextIndex; if (this.mode === 'editor') { - await this.revealEditorLine(this.filteredItems[nextIndex].item.line); + this.revealEditorLine(this.filteredItems[nextIndex].item.line); } this.render(); } @@ -150,7 +152,7 @@ export class DoomFuzzySearchPanel { if (this.mode === 'workspace') { await this.openWorkspaceItem(item.item); } else { - await this.revealEditorLine(item.item.line); + this.revealEditorLine(item.item.line); } await this.close(); } @@ -382,7 +384,8 @@ export class DoomFuzzySearchPanel { let stat: vscode.FileStat | undefined; try { stat = await vscode.workspace.fs.stat(uri); - } catch { + } catch (err) { + console.warn('[DoomFuzzySearch] stat failed:', err); return []; } if (!stat || stat.size > DoomFuzzySearchPanel.workspaceFileSizeLimit || stat.type !== vscode.FileType.File) { @@ -392,7 +395,8 @@ export class DoomFuzzySearchPanel { try { const document = await vscode.workspace.openTextDocument(uri); return this.buildWorkspaceItems(document); - } catch { + } catch (err) { + console.warn('[DoomFuzzySearch] openTextDocument failed:', err); return []; } } @@ -415,8 +419,8 @@ export class DoomFuzzySearchPanel { } /** - * Applies fuzzy filter to `currentItems` and caps results at 200. - * Editor mode: shows first 200 lines unranked for queries < 2 chars, then sorts by line number. + * Applies fuzzy filter to `currentItems` and caps results at MAX_RESULTS. + * Editor mode: shows first MAX_RESULTS lines unranked for queries < 2 chars, then sorts by line number. * Workspace mode: shows nothing until 2+ chars are typed, then groups by file via `groupWorkspaceMatches`. */ private filterItems(): void { @@ -435,7 +439,7 @@ export class DoomFuzzySearchPanel { } this.filteredItems = this.currentItems - .slice(0, 200) + .slice(0, MAX_RESULTS) .map((item) => ({ item, matches: [], @@ -460,10 +464,10 @@ export class DoomFuzzySearchPanel { .filter((entry): entry is SearchMatch => entry !== undefined); this.filteredItems = this.mode === 'workspace' - ? this.groupWorkspaceMatches(matches).slice(0, 200) + ? this.groupWorkspaceMatches(matches).slice(0, MAX_RESULTS) : matches .sort((left, right) => left.item.line - right.item.line) - .slice(0, 200); + .slice(0, MAX_RESULTS); } /** Groups matches by file (alphabetically), with lines within each file sorted by line number. */ @@ -501,7 +505,7 @@ export class DoomFuzzySearchPanel { this.filterItems(); this.render(); if (this.mode === 'editor' && this.filteredItems.length > 0) { - await this.revealEditorLine(this.filteredItems[0].item.line); + this.revealEditorLine(this.filteredItems[0].item.line); } return; case 'move': { @@ -516,7 +520,7 @@ export class DoomFuzzySearchPanel { this.activeIndex = message.index; if (this.mode === 'editor') { - await this.revealEditorLine(item.item.line); + this.revealEditorLine(item.item.line); } this.render(); return; @@ -537,7 +541,7 @@ export class DoomFuzzySearchPanel { } /** Scrolls the target editor to `line` and moves the cursor there for live preview during navigation. */ - private async revealEditorLine(line: number): Promise { + private revealEditorLine(line: number): void { const editor = this.targetEditor; if (!editor) { return; diff --git a/src/search/projectFile.ts b/src/search/projectFile.ts index a140bab..dc6f2ea 100644 --- a/src/search/projectFile.ts +++ b/src/search/projectFile.ts @@ -2,6 +2,8 @@ import * as vscode from 'vscode'; import { createFilePickerHtml, createNonce, formatFileSize, formatPermissions, formatRelativeTime, orderlessMatch } from '../panel/helpers'; import { SelectionHistory } from './selectionHistory'; +const MAX_RESULTS = 200; + // --------------------------------------------------------------------------- // Project file models // --------------------------------------------------------------------------- @@ -133,7 +135,7 @@ export class DoomProjectFilePanel { } /** Moves the active result by `delta` rows. No-op at list boundaries. */ - async moveSelection(delta: number): Promise { + moveSelection(delta: number): void { if (!this.view?.visible || this.filteredItems.length === 0) { return; } @@ -353,7 +355,7 @@ export class DoomProjectFilePanel { const query = this.query.trim().toLowerCase(); if (query.length === 0) { - this.filteredItems = this.allItems.slice(0, 200).map((item) => ({ + this.filteredItems = this.allItems.slice(0, MAX_RESULTS).map((item) => ({ item, matches: [], score: 0, @@ -378,7 +380,7 @@ export class DoomProjectFilePanel { if (bHistory !== aHistory) { return bHistory - aHistory; } return (b.item.lastModifiedMs ?? 0) - (a.item.lastModifiedMs ?? 0); }) - .slice(0, 200); + .slice(0, MAX_RESULTS); } /** Dispatches webview messages. */ diff --git a/src/search/recentProjects.ts b/src/search/recentProjects.ts index d2c7ef3..af0a6f0 100644 --- a/src/search/recentProjects.ts +++ b/src/search/recentProjects.ts @@ -113,7 +113,8 @@ export async function getRecentProjects(): Promise { let raw: unknown; try { raw = await vscode.commands.executeCommand('_workbench.getRecentlyOpened'); - } catch { + } catch (err) { + console.warn('[DoomRecentProjects] getRecentlyOpened failed:', err); return []; } @@ -190,16 +191,17 @@ export class DoomRecentProjectsPanel { private viewDisposables: vscode.Disposable[] = []; /** - * Resets state. Always returns true — no precondition needed. + * Resets state. * Pass `onProjectSelected` to intercept selection instead of opening the folder. + * Returns void (not boolean) because there is no precondition to check; the shared + * panel interface calls prepareShow() for its boolean, but this panel always proceeds. */ - prepareShow(onProjectSelected?: (item: RecentProjectItem) => Promise): boolean { + prepareShow(onProjectSelected?: (item: RecentProjectItem) => Promise): void { this.onProjectSelected = onProjectSelected; this.query = ''; this.activeIndex = 0; this.allItems = []; this.filteredItems = []; - return true; } /** Loads recent projects from VS Code's MRU list and renders them, excluding the current workspace. */ @@ -230,7 +232,7 @@ export class DoomRecentProjectsPanel { } /** Moves the active result by `delta` rows. No-op at list boundaries. */ - async moveSelection(delta: number): Promise { + moveSelection(delta: number): void { if (!this.view?.visible || this.filteredItems.length === 0) { return; } diff --git a/src/whichkey/menu.ts b/src/whichkey/menu.ts index 06b55eb..e13c1d7 100644 --- a/src/whichkey/menu.ts +++ b/src/whichkey/menu.ts @@ -146,6 +146,9 @@ export function applyTrackedUiContextCommand( } } +const BLUR_ENABLE_DELAY_MS = 200; +const SUPPRESS_WINDOW_MS = 150; + /** Narrows `unknown` to an object for safe property access on untyped packageJSON entries. */ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; @@ -554,7 +557,7 @@ function toRenderItem(state: DoomWhichKeyMenu, binding: WhichKeyBinding): Render /** Per-load random nonce for the Content-Security-Policy script-src directive. */ function getNonce(): string { - return Math.random().toString(36).slice(2, 12); + return crypto.randomUUID(); } // --------------------------------------------------------------------------- @@ -863,7 +866,7 @@ export class DoomWhichKeyMenu { return; } -if (message.type !== 'activate' || message.index === undefined) { + if (message.type !== 'activate' || message.index === undefined) { return; } @@ -1144,7 +1147,7 @@ if (message.type !== 'activate' || message.index === undefined) { color: var(--text); } -@media (max-width: 760px) { + @media (max-width: 760px) { body { padding: 6px 6px 2px; } @@ -1232,7 +1235,7 @@ if (message.type !== 'activate' || message.index === undefined) { updateGridRowCount(); if (Array.isArray(state.suppressedKeys) && state.suppressedKeys.length > 0) { - suppressUntil = Date.now() + 150; + suppressUntil = Date.now() + SUPPRESS_WINDOW_MS; state.suppressedKeys.forEach((key) => { suppressedKeys.set(key, (suppressedKeys.get(key) || 0) + 1); }); @@ -1270,7 +1273,7 @@ if (message.type !== 'activate' || message.index === undefined) { window.addEventListener('message', (event) => { if (event.data.type === 'render') { clearTimeout(blurTimer); - blurTimer = setTimeout(() => { blurEnabled = true; }, 200); + blurTimer = setTimeout(() => { blurEnabled = true; }, BLUR_ENABLE_DELAY_MS); render(event.data.state); } else if (event.data.type === 'hide') { clearTimeout(blurTimer); @@ -1293,7 +1296,7 @@ if (message.type !== 'activate' || message.index === undefined) { return; } -const bindingKey = toBindingKey(event); + const bindingKey = toBindingKey(event); if (!bindingKey) { return; } diff --git a/src/whichkey/showBindings.ts b/src/whichkey/showBindings.ts index 4f42729..c819213 100644 --- a/src/whichkey/showBindings.ts +++ b/src/whichkey/showBindings.ts @@ -140,7 +140,7 @@ function flattenWhichKeyBindings( /** Entry point: reads live config and returns the fully-flattened binding list. */ export function getFlattenedWhichKeyBindings(): WhichKeyExecutableBinding[] { return flattenWhichKeyBindings(getConfiguredWhichKeyBindings()); - } +} /** * Opens a fuzzy-searchable QuickPick over all configured which-key bindings. diff --git a/src/window/mru.ts b/src/window/mru.ts index aa24531..4fb5f70 100644 --- a/src/window/mru.ts +++ b/src/window/mru.ts @@ -4,6 +4,11 @@ import * as vscode from 'vscode'; // Editor group focus commands // --------------------------------------------------------------------------- +/** Maximum number of editor groups to walk when searching for top/bottom group. */ +const MAX_GROUP_WALK = 8; +/** Number of most-recently-used groups to remember. */ +const MAX_RECENT_GROUPS = 2; + const FOCUS_GROUP_COMMANDS: Partial> = { [vscode.ViewColumn.One]: "workbench.action.focusFirstEditorGroup", [vscode.ViewColumn.Two]: "workbench.action.focusSecondEditorGroup", @@ -158,7 +163,7 @@ export async function focusWindowUp( await executeCommand('workbench.action.focusFirstEditorGroup'); let previous = getActiveViewColumn(); - for (let i = 0; i < 8; i++) { + for (let i = 0; i < MAX_GROUP_WALK; i++) { await executeCommand('workbench.action.focusBelowGroup'); const current = getActiveViewColumn(); if (current === previous) { break; } @@ -210,8 +215,8 @@ function createEditorGroupMruToggle(): WindowMruController { recentGroups.push(viewColumn); - if (recentGroups.length > 2) { - recentGroups.splice(0, recentGroups.length - 2); + if (recentGroups.length > MAX_RECENT_GROUPS) { + recentGroups.splice(0, recentGroups.length - MAX_RECENT_GROUPS); } }; From 234b9c11cedf2a08302a530c1181f1d131810d5b Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 13:27:39 +0200 Subject: [PATCH 02/12] fix: rename fuzzyMatch to substringMatch, remove hardcoded extension id, memoize trigger bindings - Rename `fuzzyMatch` to `substringMatch` in panel/helpers.ts and all consumers (search/search.ts, buffers/openEditors.ts, whichkey/bindingsPanel.ts); update UI strings from "fuzzy search" to "search" to match the actual substring implementation - Replace hardcoded 'bearylabs.doom' extension id in menu.ts with packageJSON passed via DoomWhichKeyMenu constructor, matching the pattern used elsewhere in the codebase - Memoize getWhichKeyTriggerBindings() at module scope so package.json is parsed once per session rather than on every binding resolution - Rename src/search/fuzzy.ts to src/search/search.ts and DoomFuzzySearchPanel to DoomSearchPanel; update all imports and references in extension.ts and panel/shared.ts --- src/buffers/openEditors.ts | 6 ++--- src/extension.ts | 8 +++---- src/panel/helpers.ts | 6 ++--- src/panel/shared.ts | 16 ++++++------- src/search/{fuzzy.ts => search.ts} | 26 ++++++++++----------- src/whichkey/bindingsPanel.ts | 6 ++--- src/whichkey/menu.ts | 36 ++++++++++++++++++++++-------- 7 files changed, 61 insertions(+), 43 deletions(-) rename src/search/{fuzzy.ts => search.ts} (97%) diff --git a/src/buffers/openEditors.ts b/src/buffers/openEditors.ts index 2372b5c..925a302 100644 --- a/src/buffers/openEditors.ts +++ b/src/buffers/openEditors.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; -import { createNonce, formatFileSize, fuzzyMatch, tildeCollapse } from '../panel/helpers'; +import { createNonce, formatFileSize, substringMatch, tildeCollapse } from '../panel/helpers'; import { focusEditorGroup } from '../window/mru'; const REFRESH_DEBOUNCE_MS = 50; @@ -658,12 +658,12 @@ export class DoomOpenEditorsPanel { }; } - const searchMatch = fuzzyMatch(item.searchText, query); + const searchMatch = substringMatch(item.searchText, query); if (!searchMatch) { return undefined; } - const labelMatch = fuzzyMatch(item.label.toLowerCase(), query); + const labelMatch = substringMatch(item.label.toLowerCase(), query); return { displayMatches: labelMatch?.indices ?? [], diff --git a/src/extension.ts b/src/extension.ts index d0b5cac..6e155fd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,7 +22,7 @@ import { } from './onboarding/install'; import { DoomSharedPanel } from './panel/shared'; import { DoomFindFilePanel } from './search/findFile'; -import { DoomFuzzySearchPanel } from './search/fuzzy'; +import { DoomSearchPanel } from './search/search'; import { DoomProjectFilePanel } from './search/projectFile'; import { DoomRecentProjectsPanel } from './search/recentProjects'; import { SelectionHistory } from './search/selectionHistory'; @@ -918,14 +918,14 @@ export function activate(context: vscode.ExtensionContext) { DASHBOARD_OPEN_ON_ACTIVATION_SETTING, ...Object.keys(installDefaults), ]; - const fuzzySearchPanel = new DoomFuzzySearchPanel(); + const searchPanel = new DoomSearchPanel(); /** Opens a project folder in the current window and suppresses the dashboard on the next activation. */ const openProjectAndSkipDashboard = async (projectUri: vscode.Uri): Promise => { await context.globalState.update(SKIP_DASHBOARD_KEY, true); await vscode.commands.executeCommand('vscode.openFolder', projectUri, { forceReuseWindow: true }); }; - const whichKeyMenu = new DoomWhichKeyMenu(); + const whichKeyMenu = new DoomWhichKeyMenu(context.extension.packageJSON as { contributes?: { keybindings?: unknown[] } }); const dashboard = new DoomDashboard(context.extensionUri); let dashboardRefreshTimer: ReturnType | undefined; let terminalEscapeTimer: ReturnType | undefined; @@ -960,7 +960,7 @@ export function activate(context: vscode.ExtensionContext) { const findFilePanel = new DoomFindFilePanel(selectionHistory); const sharedPanel = new DoomSharedPanel( whichKeyMenu, - fuzzySearchPanel, + searchPanel, openEditorsPanel, whichKeyBindingsPanel, projectFilePanel, diff --git a/src/panel/helpers.ts b/src/panel/helpers.ts index 128f4fa..f4876a6 100644 --- a/src/panel/helpers.ts +++ b/src/panel/helpers.ts @@ -74,7 +74,7 @@ export function createNonce(): string { } /** Substring match. Returns undefined if query is not found as a contiguous run in text. */ -export function fuzzyMatch(text: string, query: string): FuzzyMatch | undefined { +export function substringMatch(text: string, query: string): FuzzyMatch | undefined { if (query.length === 0) { return { indices: [], score: 0 }; } @@ -100,14 +100,14 @@ export function orderlessMatch(text: string, query: string): FuzzyMatch | undefi return { indices: [], score: 0 }; } if (tokens.length === 1) { - return fuzzyMatch(text, tokens[0]); + return substringMatch(text, tokens[0]); } let totalScore = 0; const allIndices: number[] = []; for (const token of tokens) { - const match = fuzzyMatch(text, token); + const match = substringMatch(text, token); if (!match) { return undefined; } diff --git a/src/panel/shared.ts b/src/panel/shared.ts index 38b4542..e55ae40 100644 --- a/src/panel/shared.ts +++ b/src/panel/shared.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { DoomOpenEditorsPanel } from '../buffers/openEditors'; import { DoomFindFilePanel } from '../search/findFile'; -import { DoomFuzzySearchPanel } from '../search/fuzzy'; +import { DoomSearchPanel } from '../search/search'; import { DoomProjectFilePanel } from '../search/projectFile'; import { DoomRecentProjectsPanel } from '../search/recentProjects'; import { DoomWhichKeyBindingsPanel } from '../whichkey/bindingsPanel'; @@ -33,7 +33,7 @@ export class DoomSharedPanel implements vscode.WebviewViewProvider { constructor( private readonly whichKeyMenu: DoomWhichKeyMenu, - private readonly fuzzySearchPanel: DoomFuzzySearchPanel, + private readonly searchPanel: DoomSearchPanel, private readonly openEditorsPanel: DoomOpenEditorsPanel, private readonly whichKeyBindingsPanel: DoomWhichKeyBindingsPanel, private readonly projectFilePanel: DoomProjectFilePanel, @@ -157,21 +157,21 @@ export class DoomSharedPanel implements vscode.WebviewViewProvider { /** Opens fuzzy search for the active editor. No-op if no editor is open. */ async showFuzzySearch(): Promise { - if (!this.fuzzySearchPanel.prepareShow()) { + if (!this.searchPanel.prepareShow()) { return; } - await this.showMode('search', this.fuzzySearchPanel); + await this.showMode('search', this.searchPanel); } /** Opens workspace search, then kicks off file indexing after the panel is visible. No-op if no folder is open. */ async showWorkspaceSearch(): Promise { - if (!this.fuzzySearchPanel.prepareShowWorkspace()) { + if (!this.searchPanel.prepareShowWorkspace()) { return; } - await this.showMode('search', this.fuzzySearchPanel); - await this.fuzzySearchPanel.loadPreparedWorkspaceItems(); + await this.showMode('search', this.searchPanel); + await this.searchPanel.loadPreparedWorkspaceItems(); } /** Opens the buffer/open-editors picker. */ @@ -277,7 +277,7 @@ export class DoomSharedPanel implements vscode.WebviewViewProvider { ); await vscode.commands.executeCommand( 'setContext', - DoomFuzzySearchPanel.visibleContextKey, + DoomSearchPanel.visibleContextKey, isVisible && this.activeMode === 'search' ); await vscode.commands.executeCommand( diff --git a/src/search/fuzzy.ts b/src/search/search.ts similarity index 97% rename from src/search/fuzzy.ts rename to src/search/search.ts index 0593e0b..393c561 100644 --- a/src/search/fuzzy.ts +++ b/src/search/search.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { createNonce, fuzzyMatch } from '../panel/helpers'; +import { createNonce, substringMatch } from '../panel/helpers'; const MAX_RESULTS = 200; @@ -62,7 +62,7 @@ interface SearchOptions { type SearchMode = 'editor' | 'workspace'; -export class DoomFuzzySearchPanel { +export class DoomSearchPanel { static readonly visibleContextKey = 'doom.fuzzySearchVisible'; private static readonly workspaceExcludeGlob = '**/{.git,node_modules,out,dist,coverage,build,.next}/**'; @@ -197,7 +197,7 @@ export class DoomFuzzySearchPanel { /** Syncs the `doom.fuzzySearchVisible` context key so keybindings can scope to panel visibility. */ private async updateVisibilityContext(isVisible: boolean): Promise { - await vscode.commands.executeCommand('setContext', DoomFuzzySearchPanel.visibleContextKey, isVisible); + await vscode.commands.executeCommand('setContext', DoomSearchPanel.visibleContextKey, isVisible); } /** Re-initializes and re-renders when the panel becomes visible again — handles both modes. */ @@ -255,7 +255,7 @@ export class DoomFuzzySearchPanel { this.query = ''; } if (options.notifyWhenMissing) { - void vscode.window.showInformationMessage('Open a file first to use fuzzy search.'); + void vscode.window.showInformationMessage('Open a file first to use search.'); } return false; } @@ -342,15 +342,15 @@ export class DoomFuzzySearchPanel { this.loading = true; this.render(); - const files = await vscode.workspace.findFiles('**/*', DoomFuzzySearchPanel.workspaceExcludeGlob); + const files = await vscode.workspace.findFiles('**/*', DoomSearchPanel.workspaceExcludeGlob); const items: SearchItem[] = []; - for (let i = 0; i < files.length; i += DoomFuzzySearchPanel.loadBatchSize) { + for (let i = 0; i < files.length; i += DoomSearchPanel.loadBatchSize) { if (loadId !== this.loadSequence || this.mode !== 'workspace') { return; } - const batch = files.slice(i, i + DoomFuzzySearchPanel.loadBatchSize); + const batch = files.slice(i, i + DoomSearchPanel.loadBatchSize); const results = await Promise.allSettled(batch.map((uri) => this.loadFileItems(uri))); for (const result of results) { @@ -388,7 +388,7 @@ export class DoomFuzzySearchPanel { console.warn('[DoomFuzzySearch] stat failed:', err); return []; } - if (!stat || stat.size > DoomFuzzySearchPanel.workspaceFileSizeLimit || stat.type !== vscode.FileType.File) { + if (!stat || stat.size > DoomSearchPanel.workspaceFileSizeLimit || stat.type !== vscode.FileType.File) { return []; } @@ -450,7 +450,7 @@ export class DoomFuzzySearchPanel { const matches = this.currentItems .map((item) => { - const match = fuzzyMatch(item.searchText, query); + const match = substringMatch(item.searchText, query); if (!match) { return undefined; } @@ -586,8 +586,8 @@ export class DoomFuzzySearchPanel { emptyText: this.getEmptyText(), items: this.toRenderItems(), placeholder: this.mode === 'workspace' - ? 'Type to fuzzy search project' - : 'Type to fuzzy search current file', + ? 'Type to search project' + : 'Type to search current file', promptLabel: this.mode === 'workspace' ? `Search (Project ${this.getWorkspaceLabel()}):` : 'Go to line:', @@ -676,7 +676,7 @@ export class DoomFuzzySearchPanel { } if (this.mode === 'workspace' && this.query.trim().length === 0) { - return 'Type to fuzzy search project.'; + return 'Type to search project.'; } return 'No matches.'; @@ -905,7 +905,7 @@ export class DoomFuzzySearchPanel {
0/0
- +
diff --git a/src/whichkey/bindingsPanel.ts b/src/whichkey/bindingsPanel.ts index 703809e..e0395d0 100644 --- a/src/whichkey/bindingsPanel.ts +++ b/src/whichkey/bindingsPanel.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { createNonce, fuzzyMatch } from '../panel/helpers'; +import { createNonce, substringMatch } from '../panel/helpers'; import { executeWhichKeyBindingCommands } from './bindings'; import { getFlattenedWhichKeyBindings, @@ -146,12 +146,12 @@ export class DoomWhichKeyBindingsPanel { }; } - const searchMatch = fuzzyMatch(item.searchText, query); + const searchMatch = substringMatch(item.searchText, query); if (!searchMatch) { return undefined; } - const pathMatch = fuzzyMatch(item.path.toLowerCase(), query); + const pathMatch = substringMatch(item.path.toLowerCase(), query); return { index, item, diff --git a/src/whichkey/menu.ts b/src/whichkey/menu.ts index e13c1d7..deb9cd7 100644 --- a/src/whichkey/menu.ts +++ b/src/whichkey/menu.ts @@ -392,19 +392,25 @@ export function evaluateWhenExpression( return new WhenExpressionParser(contextValues, tokens).parse(); } +type PackageJsonWithKeybindings = { + contributes?: { + keybindings?: unknown[]; + }; +}; + +let cachedTriggerBindings: WhichKeyTriggerBinding[] | undefined; + /** * Reads package.json at runtime to discover keys routed through `whichkey.triggerKey`. * Args can be a plain string (key only) or an object with an optional condition. + * Result is memoized — package.json is static for the session. */ -function getWhichKeyTriggerBindings(): WhichKeyTriggerBinding[] { - const extension = vscode.extensions.getExtension('bearylabs.doom'); - const packageJson = extension?.packageJSON as { - contributes?: { - keybindings?: unknown[]; - }; - } | undefined; +function getWhichKeyTriggerBindings(packageJson: PackageJsonWithKeybindings): WhichKeyTriggerBinding[] { + if (cachedTriggerBindings) { + return cachedTriggerBindings; + } - return (packageJson?.contributes?.keybindings ?? []).flatMap((entry) => { + cachedTriggerBindings = (packageJson.contributes?.keybindings ?? []).flatMap((entry) => { if (!isRecord(entry) || entry.command !== 'doom.triggerKey' || typeof entry.when !== 'string') { return []; } @@ -426,6 +432,8 @@ function getWhichKeyTriggerBindings(): WhichKeyTriggerBinding[] { when: entry.when, }]; }); + + return cachedTriggerBindings; } /** @@ -491,7 +499,7 @@ function resolveConditionalBinding(state: DoomWhichKeyMenu, binding: WhichKeyBin const triggeredCondition = selectTriggeredConditionForKey( binding.key, contextValues, - getWhichKeyTriggerBindings(), + getWhichKeyTriggerBindings(state.extensionPackageJson), ); if (triggeredCondition) { @@ -568,7 +576,9 @@ export class DoomWhichKeyMenu { static readonly visibleContextKey = 'whichkeyVisible'; private bigModeEnabled = false; + private readonly packageJson: PackageJsonWithKeybindings; private currentBindings: WhichKeyBinding[] = []; + private currentItems: RenderItem[] = []; private currentShowContext: ShowContext = { terminalFocus: false, @@ -589,6 +599,14 @@ export class DoomWhichKeyMenu { private view: vscode.WebviewView | undefined; private viewDisposables: vscode.Disposable[] = []; + constructor(packageJson: PackageJsonWithKeybindings) { + this.packageJson = packageJson; + } + + get extensionPackageJson(): PackageJsonWithKeybindings { + return this.packageJson; + } + get isBigModeEnabled(): boolean { return this.bigModeEnabled; } From 205356a8750ea68732327ce3ef4d26b355e452db Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 13:36:15 +0200 Subject: [PATCH 03/12] refactor: deduplicate isRecord, createNonce, and managed vim setting keys Export isRecord and createNonce from panel/helpers as single sources of truth. Remove the duplicate isRecord from whichkey/bindings and menu, and replace getNonce in menu with createNonce. Expand DOOM_MANAGED_VIM_BINDING_SETTINGS in vimBindings to cover all four managed vim arrays (NonRecursive + plain, both modes), and replace the two hardcoded keysToCheck arrays in extension.ts with that constant. --- src/extension.ts | 15 +++------------ src/onboarding/vimBindings.ts | 1 + src/panel/helpers.ts | 5 +++++ src/whichkey/bindings.ts | 6 +----- src/whichkey/menu.ts | 12 ++---------- 5 files changed, 12 insertions(+), 27 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 6e155fd..42c2004 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { type VimBindingConflict, type VimBindingConflictDecision, } from './onboarding/install'; +import { DOOM_MANAGED_VIM_BINDING_SETTINGS } from './onboarding/vimBindings'; import { DoomSharedPanel } from './panel/shared'; import { DoomFindFilePanel } from './search/findFile'; import { DoomSearchPanel } from './search/search'; @@ -481,12 +482,7 @@ function containsStaleCommand(value: unknown): boolean { */ async function cleanStaleSettings(): Promise { const config = vscode.workspace.getConfiguration(); - const keysToCheck = [ - "vim.normalModeKeyBindingsNonRecursive", - "vim.visualModeKeyBindingsNonRecursive", - "vim.normalModeKeyBindings", - "vim.visualModeKeyBindings", - ]; + const keysToCheck = DOOM_MANAGED_VIM_BINDING_SETTINGS; const cleaned: string[] = []; @@ -703,12 +699,7 @@ function detectStaleState(context: vscode.ExtensionContext): StaleDetectionResul const conflicts = detectConflictingExtensions(); const config = vscode.workspace.getConfiguration(); - const keysToCheck = [ - "vim.normalModeKeyBindingsNonRecursive", - "vim.visualModeKeyBindingsNonRecursive", - "vim.normalModeKeyBindings", - "vim.visualModeKeyBindings", - ]; + const keysToCheck = DOOM_MANAGED_VIM_BINDING_SETTINGS; const hasStaleSettings = keysToCheck.some((key) => { const inspected = config.inspect(key); diff --git a/src/onboarding/vimBindings.ts b/src/onboarding/vimBindings.ts index 60eb520..2d4c72d 100644 --- a/src/onboarding/vimBindings.ts +++ b/src/onboarding/vimBindings.ts @@ -5,6 +5,7 @@ const DOOM_VIM_BINDING_MODES = [ const DOOM_VIM_BINDING_ARRAY_KINDS = [ 'KeyBindingsNonRecursive', + 'KeyBindings', ] as const; type VimBindingEntry = { diff --git a/src/panel/helpers.ts b/src/panel/helpers.ts index f4876a6..3b2dfad 100644 --- a/src/panel/helpers.ts +++ b/src/panel/helpers.ts @@ -63,6 +63,11 @@ export function formatPermissions(mode: number): string { + (mode & 0o001 ? 'x' : '-'); } +/** Guards against null — `typeof null === 'object'` would otherwise pass. */ +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + export interface FuzzyMatch { indices: number[]; score: number; diff --git a/src/whichkey/bindings.ts b/src/whichkey/bindings.ts index 0e18a6c..8575dde 100644 --- a/src/whichkey/bindings.ts +++ b/src/whichkey/bindings.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { isRecord } from '../panel/helpers'; // --------------------------------------------------------------------------- // Which-key binding model @@ -20,11 +21,6 @@ export interface WhichKeyBinding { // Binding validation and lookup // --------------------------------------------------------------------------- -/** Guards against null — `typeof null === 'object'` would otherwise pass. */ -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - /** Minimal structural check — intentionally loose so unknown extra fields pass through. */ function isWhichKeyBinding(value: unknown): value is WhichKeyBinding { if (!isRecord(value)) { diff --git a/src/whichkey/menu.ts b/src/whichkey/menu.ts index deb9cd7..61d4116 100644 --- a/src/whichkey/menu.ts +++ b/src/whichkey/menu.ts @@ -5,6 +5,7 @@ import { getConfiguredWhichKeyBindings, type WhichKeyBinding, } from './bindings'; +import { createNonce, isRecord } from '../panel/helpers'; // --------------------------------------------------------------------------- // Which-key menu models @@ -149,11 +150,6 @@ export function applyTrackedUiContextCommand( const BLUR_ENABLE_DELAY_MS = 200; const SUPPRESS_WINDOW_MS = 150; -/** Narrows `unknown` to an object for safe property access on untyped packageJSON entries. */ -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - /** Maps literal chars to symbolic names matching the webview keydown handler. */ function normalizeBindingKey(value: string): string { if (value === '\t') { @@ -563,10 +559,6 @@ function toRenderItem(state: DoomWhichKeyMenu, binding: WhichKeyBinding): Render } } -/** Per-load random nonce for the Content-Security-Policy script-src directive. */ -function getNonce(): string { - return crypto.randomUUID(); -} // --------------------------------------------------------------------------- // Which-key panel controller @@ -1030,7 +1022,7 @@ export class DoomWhichKeyMenu { * The blur listener is delayed 200ms post-render so VS Code can settle focus before the guard activates. */ private getHtml(webview: vscode.Webview): string { - const nonce = getNonce(); + const nonce = createNonce(); const csp = [ "default-src 'none'", `style-src ${webview.cspSource} 'unsafe-inline'`, From bacf6775c3f6a637b0717c2e0884e0b5a7334256 Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 14:46:10 +0200 Subject: [PATCH 04/12] fix(whichkey): interpolate webview timing constants into menu client script BLUR_ENABLE_DELAY_MS and SUPPRESS_WINDOW_MS are Node-side module constants, but the webview client script referenced them as bare identifiers instead of interpolating their values. Since they don't exist in the webview's JS sandbox, every render message threw a ReferenceError before render() ran, leaving the which-key menu blank while keybindings and the bindings QuickPick still worked. Wrap both usages in ${...} so the numeric values are baked into the generated client JS, restoring menu rendering. --- src/whichkey/menu.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/whichkey/menu.ts b/src/whichkey/menu.ts index 61d4116..64b3df0 100644 --- a/src/whichkey/menu.ts +++ b/src/whichkey/menu.ts @@ -1245,7 +1245,7 @@ export class DoomWhichKeyMenu { updateGridRowCount(); if (Array.isArray(state.suppressedKeys) && state.suppressedKeys.length > 0) { - suppressUntil = Date.now() + SUPPRESS_WINDOW_MS; + suppressUntil = Date.now() + ${SUPPRESS_WINDOW_MS}; state.suppressedKeys.forEach((key) => { suppressedKeys.set(key, (suppressedKeys.get(key) || 0) + 1); }); @@ -1283,7 +1283,7 @@ export class DoomWhichKeyMenu { window.addEventListener('message', (event) => { if (event.data.type === 'render') { clearTimeout(blurTimer); - blurTimer = setTimeout(() => { blurEnabled = true; }, BLUR_ENABLE_DELAY_MS); + blurTimer = setTimeout(() => { blurEnabled = true; }, ${BLUR_ENABLE_DELAY_MS}); render(event.data.state); } else if (event.data.type === 'hide') { clearTimeout(blurTimer); From 2a2aaca75569065945d3e630ffc8cc23cdcfcbef Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 14:48:46 +0200 Subject: [PATCH 05/12] fix: separate install-managed and stale-scanned vim binding sets DOOM_MANAGED_VIM_BINDING_SETTINGS was widened to four keys, conflating two distinct concerns: the arrays Doom installs/merges into (the two NonRecursive arrays, backing isDoomManagedVimBindingSetting used by install and dashboard detection) and the broader set scanned for stale Doom commands during cleanup. Restore DOOM_MANAGED_VIM_BINDING_SETTINGS to the two NonRecursive keys and add DOOM_STALE_VIM_BINDING_SETTINGS covering all four arrays for the stale-scan and detection paths in extension.ts. Extend the centralization test to assert both sets. --- src/extension.ts | 6 +++--- src/onboarding/vimBindings.ts | 10 ++++++++++ src/test/extension.test.ts | 7 +++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 42c2004..37bc9f4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,7 +20,7 @@ import { type VimBindingConflict, type VimBindingConflictDecision, } from './onboarding/install'; -import { DOOM_MANAGED_VIM_BINDING_SETTINGS } from './onboarding/vimBindings'; +import { DOOM_STALE_VIM_BINDING_SETTINGS } from './onboarding/vimBindings'; import { DoomSharedPanel } from './panel/shared'; import { DoomFindFilePanel } from './search/findFile'; import { DoomSearchPanel } from './search/search'; @@ -482,7 +482,7 @@ function containsStaleCommand(value: unknown): boolean { */ async function cleanStaleSettings(): Promise { const config = vscode.workspace.getConfiguration(); - const keysToCheck = DOOM_MANAGED_VIM_BINDING_SETTINGS; + const keysToCheck = DOOM_STALE_VIM_BINDING_SETTINGS; const cleaned: string[] = []; @@ -699,7 +699,7 @@ function detectStaleState(context: vscode.ExtensionContext): StaleDetectionResul const conflicts = detectConflictingExtensions(); const config = vscode.workspace.getConfiguration(); - const keysToCheck = DOOM_MANAGED_VIM_BINDING_SETTINGS; + const keysToCheck = DOOM_STALE_VIM_BINDING_SETTINGS; const hasStaleSettings = keysToCheck.some((key) => { const inspected = config.inspect(key); diff --git a/src/onboarding/vimBindings.ts b/src/onboarding/vimBindings.ts index 2d4c72d..31e0cb8 100644 --- a/src/onboarding/vimBindings.ts +++ b/src/onboarding/vimBindings.ts @@ -5,6 +5,11 @@ const DOOM_VIM_BINDING_MODES = [ const DOOM_VIM_BINDING_ARRAY_KINDS = [ 'KeyBindingsNonRecursive', +] as const; + +// Recursive variants are scanned for stale Doom commands during cleanup, but Doom never installs into them. +const DOOM_STALE_VIM_BINDING_ARRAY_KINDS = [ + 'KeyBindingsNonRecursive', 'KeyBindings', ] as const; @@ -35,6 +40,11 @@ export const DOOM_MANAGED_VIM_BINDING_SETTINGS = DOOM_VIM_BINDING_MODES.flatMap( DOOM_VIM_BINDING_ARRAY_KINDS.map((kind) => `vim.${mode}${kind}`) )); +/** Vim binding arrays scanned for stale Doom commands during cleanup and detection — a superset of the install-managed set. */ +export const DOOM_STALE_VIM_BINDING_SETTINGS = DOOM_VIM_BINDING_MODES.flatMap((mode) => ( + DOOM_STALE_VIM_BINDING_ARRAY_KINDS.map((kind) => `vim.${mode}${kind}`) +)); + const DOOM_MANAGED_VIM_BINDING_SETTING_SET = new Set(DOOM_MANAGED_VIM_BINDING_SETTINGS); export function isDoomManagedVimBindingSetting(key: string): boolean { diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 51eda3b..878dc8c 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -16,6 +16,7 @@ import { import { applyDefaultsToConfiguration, hasUserOwnedSettingValue, runInstallFlow } from '../onboarding/install'; import { DOOM_MANAGED_VIM_BINDING_SETTINGS, + DOOM_STALE_VIM_BINDING_SETTINGS, getDoomManagedVimBindingConflictKey, hasEquivalentDoomManagedVimBinding, isDoomManagedVimBindingSetting, @@ -125,6 +126,12 @@ suite('Extension Test Suite', () => { 'vim.normalModeKeyBindingsNonRecursive', 'vim.visualModeKeyBindingsNonRecursive', ]); + assert.deepStrictEqual(DOOM_STALE_VIM_BINDING_SETTINGS, [ + 'vim.normalModeKeyBindingsNonRecursive', + 'vim.normalModeKeyBindings', + 'vim.visualModeKeyBindingsNonRecursive', + 'vim.visualModeKeyBindings', + ]); assert.strictEqual(isDoomManagedVimBindingSetting('vim.normalModeKeyBindingsNonRecursive'), true); assert.strictEqual(isDoomManagedVimBindingSetting('vim.insertModeKeyBindingsNonRecursive'), false); assert.strictEqual( From a23f8ea3db186f082ce98a1b0399f7e78e3b5595 Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 15:21:21 +0200 Subject: [PATCH 06/12] refactor: extract shared webview controller and HTML builder Collapse the six bottom-panel pickers (find-file, project files, recent projects, open editors, search, which-key bindings) onto a common base and a single HTML template, removing the duplicated controller lifecycle and embedded CSS/JS scaffolding. - Add DoomWebviewController (src/panel/controller.ts) owning the view/ready/ viewDisposables/activeIndex/query state, attach/detach, the resolveWebviewView bootstrap, the render guard, the ready|query|move|activate|close dispatch skeleton, updateVisibilityContext, and close(). Panels override only getHtml, filterItems, activateSelection, buildRenderState, itemCount, plus small hooks (onReady, afterRender, onVisibilityChanged, extraViewDisposables, onMessage, updateViewMetadata, onDetach/onDispose). - Add createPanelHtml (src/panel/helpers.ts) producing the shared CSP, chrome CSS, appendHighlightedText, the focus/setSelectionRange + forceQuery reconcile, and the keydown handler (Escape/Arrow/Enter/Ctrl+J/Ctrl+K). createFilePickerHtml delegates to it; each panel passes only its row layout + render-item builder. Normalize the small CSS drifts (promptbar gap, status alignment, paddings) to one canonical chrome. - Standardize selection movement on the in-webview handler: drop the redundant ctrl+j/k keybindings, the *Move{Down,Up} commands/registrations, and the now unused moveSelection(). Fixes the double-move on the file pickers and makes Ctrl+J/K an alias of the arrows on every panel. No user-visible behavior change beyond unifying Ctrl+J/K navigation. The two workspace FileSystemWatchers are intentionally left in place. --- package.json | 50 --- src/buffers/openEditors.ts | 610 +++++++----------------------- src/extension.ts | 48 --- src/panel/controller.ts | 213 +++++++++++ src/panel/helpers.ts | 380 +++++++++++-------- src/search/findFile.ts | 169 +++------ src/search/projectFile.ts | 165 ++------- src/search/recentProjects.ts | 145 +------- src/search/search.ts | 677 ++++++++-------------------------- src/whichkey/bindingsPanel.ts | 518 +++++--------------------- 10 files changed, 917 insertions(+), 2058 deletions(-) create mode 100644 src/panel/controller.ts diff --git a/package.json b/package.json index b4c5cbc..38d8a85 100644 --- a/package.json +++ b/package.json @@ -1669,31 +1669,11 @@ "title": "Find File in Project", "category": "Doom" }, - { - "command": "doom.projectFileMoveDown", - "title": "Project File: Move Selection Down", - "category": "Doom" - }, - { - "command": "doom.projectFileMoveUp", - "title": "Project File: Move Selection Up", - "category": "Doom" - }, { "command": "doom.showRecentProjects", "title": "Show Recent Projects", "category": "Doom" }, - { - "command": "doom.recentProjectsMoveDown", - "title": "Recent Projects: Move Selection Down", - "category": "Doom" - }, - { - "command": "doom.recentProjectsMoveUp", - "title": "Recent Projects: Move Selection Up", - "category": "Doom" - }, { "command": "doom.findFile", "title": "Find File", @@ -2877,36 +2857,6 @@ "command": "workbench.action.quickInputBack", "when": "inQuickInput && !whichkeyVisible" }, - { - "key": "ctrl+j", - "command": "doom.recentProjectsMoveDown", - "when": "doom.recentProjectsVisible" - }, - { - "key": "ctrl+k", - "command": "doom.recentProjectsMoveUp", - "when": "doom.recentProjectsVisible" - }, - { - "key": "ctrl+j", - "command": "doom.projectFileMoveDown", - "when": "doom.projectFileVisible" - }, - { - "key": "ctrl+k", - "command": "doom.projectFileMoveUp", - "when": "doom.projectFileVisible" - }, - { - "key": "ctrl+j", - "command": "doom.findFileMoveDown", - "when": "doom.findFileVisible" - }, - { - "key": "ctrl+k", - "command": "doom.findFileMoveUp", - "when": "doom.findFileVisible" - }, { "key": "ctrl+j", "command": "workbench.action.quickOpenSelectNext", diff --git a/src/buffers/openEditors.ts b/src/buffers/openEditors.ts index 925a302..8d9039f 100644 --- a/src/buffers/openEditors.ts +++ b/src/buffers/openEditors.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; -import { createNonce, formatFileSize, substringMatch, tildeCollapse } from '../panel/helpers'; +import { DoomWebviewController } from '../panel/controller'; +import { createNonce, createPanelHtml, formatFileSize, substringMatch, tildeCollapse } from '../panel/helpers'; import { focusEditorGroup } from '../window/mru'; const REFRESH_DEBOUNCE_MS = 50; @@ -51,12 +52,6 @@ interface OpenEditorState { title: string; } -interface OpenEditorMessage { - index?: number; - query?: string; - type: 'activate' | 'close' | 'move' | 'query' | 'ready'; -} - /** Formats a view column number as a short group label, e.g. column 2 → "G2". */ function viewColumnToGroupLabel(viewColumn: vscode.ViewColumn): string { return `G${viewColumn}`; @@ -483,22 +478,114 @@ async function openTabInGroupWithOptions( // Open editors panel // --------------------------------------------------------------------------- -export class DoomOpenEditorsPanel { +/** Five-column buffer row: label · flags · size · kind · location. */ +const OPEN_EDITORS_LAYOUT_CSS = ` .item { + display: grid; + grid-template-columns: minmax(18ch, 28ch) 4ch 6ch 10ch minmax(0, 1fr); + gap: 2ch; + align-items: center; + min-height: var(--line-height); + padding: 0 10px; + border: none; + background: transparent; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; + } + + .item.active { + background: var(--selected); + outline: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); + outline-offset: -1px; + } + + .flags, + .location { + color: var(--muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + + .kind { + color: var(--accent); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + + .flags, + .kind { + overflow: hidden; + text-overflow: ellipsis; + } + + .size { + color: var(--warning); + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; + } + + .label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .location { + min-width: 0; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + }`; + +/** Builds one buffer row: highlighted label, vim flags, size, kind badge, location. */ +const OPEN_EDITORS_RENDER_ITEM = ` const button = document.createElement('button'); + button.type = 'button'; + button.className = item.index === state.activeIndex ? 'item active' : 'item'; + button.dataset.index = String(item.index); + + const label = document.createElement('span'); + label.className = 'label'; + appendHighlightedText(label, item.label, item.matches); + + const flags = document.createElement('span'); + flags.className = 'flags'; + flags.textContent = item.flags; + + const kind = document.createElement('span'); + kind.className = 'kind'; + kind.textContent = item.kind; + + const size = document.createElement('span'); + size.className = 'size'; + size.textContent = item.size ?? ''; + + const location = document.createElement('span'); + location.className = 'location'; + location.textContent = item.location; + + button.append(label, flags, size, kind, location); + button.addEventListener('click', () => { + vscode.postMessage({ type: 'activate', index: item.index }); + }); + results.appendChild(button);`; + +export class DoomOpenEditorsPanel extends DoomWebviewController { static readonly visibleContextKey = 'doom.openEditorsVisible'; + protected readonly visibleContextKey = DoomOpenEditorsPanel.visibleContextKey; + private accepted = false; - private activeIndex = 0; private filter = true; private items: OpenEditorItem[] = []; private lastPreviewKey: string | undefined; private matches: OpenEditorMatch[] = []; - private query = ''; - private ready = false; private refreshTimer: ReturnType | undefined; private restoreTabKey: string | undefined; private targetGroup: vscode.ViewColumn | undefined; - private view: vscode.WebviewView | undefined; - private viewDisposables: vscode.Disposable[] = []; /** Snapshots the active tab for post-cancel restore, resets state, and loads current open editors. */ prepareShow(resetQuery = true, filter = true): void { @@ -516,47 +603,37 @@ export class DoomOpenEditorsPanel { void this.refreshItems(); } - /** Wires the panel to an already-created WebviewView (e.g. on sidebar restore). */ - attachToView(webviewView: vscode.WebviewView): void { - this.resolveWebviewView(webviewView); + protected get itemCount(): number { + return this.matches.length; } - /** Tears down listeners and clears the view reference without destroying the panel instance. */ - detachFromView(): void { - this.viewDisposables.forEach((disposable) => disposable.dispose()); - this.viewDisposables = []; - this.view = undefined; - this.ready = false; + /** Stamps the panel title and workspace name onto the sidebar pane header. */ + protected updateViewMetadata(): void { + if (!this.view) { + return; + } + + this.view.title = 'Switch to buffer'; + this.view.description = getWorkspaceLabel(); } - /** - * Bootstraps the WebviewView: injects HTML, wires dispose/visibility/tab-change/message listeners. - * Also subscribes to `onDidChangeTabs` so the list stays live while the panel is open. - */ - resolveWebviewView(webviewView: vscode.WebviewView): void { - this.viewDisposables.forEach((disposable) => disposable.dispose()); - this.viewDisposables = []; - this.view = webviewView; - webviewView.webview.options = { - enableScripts: true, - }; - webviewView.webview.html = this.getHtml(webviewView.webview); - this.updateViewMetadata(); - - this.viewDisposables.push( - webviewView.onDidDispose(() => { - if (this.view === webviewView) { - this.view = undefined; - this.ready = false; - } - }), - webviewView.onDidChangeVisibility(() => { - if (!webviewView.visible) { - return; - } + /** Live-previews the active item after every render (initial reveal included). */ + protected async afterRender(): Promise { + await this.previewSelection(); + } - this.scheduleRefresh(); - }), + /** Refreshes the open-editor list each time the panel is revealed. */ + protected onVisibilityChanged(visible: boolean): void { + if (!visible) { + return; + } + + this.scheduleRefresh(); + } + + /** Keeps the list live while open by refreshing on any tab change. */ + protected extraViewDisposables(webviewView: vscode.WebviewView): vscode.Disposable[] { + return [ vscode.window.tabGroups.onDidChangeTabs(() => { if (!webviewView.visible) { return; @@ -564,20 +641,7 @@ export class DoomOpenEditorsPanel { this.scheduleRefresh(); }), - webviewView.webview.onDidReceiveMessage((message: OpenEditorMessage) => { - void this.handleMessage(message); - }) - ); - } - - /** Stamps the panel title and workspace name onto the sidebar pane header. */ - private updateViewMetadata(): void { - if (!this.view) { - return; - } - - this.view.title = 'Switch to buffer'; - this.view.description = getWorkspaceLabel(); + ]; } /** Coalesces rapid onDidChangeTabs bursts into a single refreshItems call after 50 ms of quiet. */ @@ -645,7 +709,7 @@ export class DoomOpenEditorsPanel { * Fuzzy-filters items by `searchText` but highlights matches against `label` only. * Empty query shows all tabs unranked. Clamps `activeIndex` to stay in bounds. */ - private filterItems(): void { + protected filterItems(): void { const query = this.query.trim().toLowerCase(); const matches = this.items .map((item, index) => { @@ -687,51 +751,11 @@ export class DoomOpenEditorsPanel { : Math.min(this.activeIndex, this.matches.length - 1); } - /** Dispatches webview messages. Query and move changes also trigger a live preview of the active item. */ - private async handleMessage(message: OpenEditorMessage): Promise { - switch (message.type) { - case 'ready': - this.ready = true; - this.render(); - await this.previewSelection(); - return; - case 'query': - this.query = message.query ?? ''; - this.filterItems(); - this.render(); - await this.previewSelection(); - return; - case 'move': { - if (this.matches.length === 0 || message.index === undefined) { - return; - } - - this.activeIndex = Math.min(Math.max(message.index, 0), this.matches.length - 1); - this.render(); - await this.previewSelection(); - return; - } - case 'activate': { - if (message.index !== undefined) { - this.activeIndex = Math.min(Math.max(message.index, 0), this.matches.length - 1); - } - - await this.activateSelection(); - return; - } - case 'close': - await this.close(); - return; - default: - return; - } - } - /** * Opens the selected tab in `targetGroup`. Falls back to `revealExistingTab` for unsupported * input types, then attempts to move it to the target group. Shows a warning if the move fails. */ - private async activateSelection(): Promise { + protected async activateSelection(): Promise { const match = this.matches[this.activeIndex]; if (!match) { return; @@ -860,18 +884,14 @@ export class DoomOpenEditorsPanel { } /** Closes the panel then restores the pre-search editor state if the user cancelled. */ - private async close(): Promise { + protected async close(): Promise { await vscode.commands.executeCommand('workbench.action.closePanel'); await this.restorePreviewIfNeeded(); } - /** Serializes current match/index state and posts it to the webview. Guards against rendering before 'ready'. */ - private render(): void { - if (!this.view || !this.ready || !this.view.visible) { - return; - } - - const state: OpenEditorState = { + /** Serializes current match/index state into the render payload. */ + protected buildRenderState(): OpenEditorState { + return { activeIndex: this.activeIndex, emptyText: this.matches.length === 0 ? 'No open editors match.' : '', items: this.matches.map((entry, index) => ({ @@ -890,365 +910,15 @@ export class DoomOpenEditorsPanel { statusLabel: `${this.matches.length === 0 ? 0 : this.activeIndex + 1}/${this.matches.length}`, title: `Switch to buffer (${getWorkspaceLabel()})`, }; - - void this.view.webview.postMessage({ - type: 'render', - state, - }); } - /** - * Generates the full webview HTML. Nonce-locked CSP prevents script injection. - * The embedded script owns all DOM interaction and communicates exclusively via postMessage. - */ - private getHtml(webview: vscode.Webview): string { - const nonce = createNonce(); - const csp = [ - "default-src 'none'", - `style-src ${webview.cspSource} 'unsafe-inline'`, - `script-src 'nonce-${nonce}'`, - ].join('; '); - - return ` - - - - - - Open Editors - - - -
-
-
0/0
-
Switch to buffer:
- -
-
- -
- - -`; } } diff --git a/src/extension.ts b/src/extension.ts index 37bc9f4..098742d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1344,20 +1344,6 @@ export function activate(context: vscode.ExtensionContext) { } ); - const findFileMoveDownCmd = vscode.commands.registerCommand( - 'doom.findFileMoveDown', - () => { - void findFilePanel.moveSelection(1); - } - ); - - const findFileMoveUpCmd = vscode.commands.registerCommand( - 'doom.findFileMoveUp', - () => { - void findFilePanel.moveSelection(-1); - } - ); - const showRecentProjectsCmd = vscode.commands.registerCommand( 'doom.showRecentProjects', () => { @@ -1365,34 +1351,6 @@ export function activate(context: vscode.ExtensionContext) { } ); - const recentProjectsMoveDownCmd = vscode.commands.registerCommand( - 'doom.recentProjectsMoveDown', - () => { - void recentProjectsPanel.moveSelection(1); - } - ); - - const recentProjectsMoveUpCmd = vscode.commands.registerCommand( - 'doom.recentProjectsMoveUp', - () => { - void recentProjectsPanel.moveSelection(-1); - } - ); - - const projectFileMoveDownCmd = vscode.commands.registerCommand( - 'doom.projectFileMoveDown', - () => { - void projectFilePanel.moveSelection(1); - } - ); - - const projectFileMoveUpCmd = vscode.commands.registerCommand( - 'doom.projectFileMoveUp', - () => { - void projectFilePanel.moveSelection(-1); - } - ); - const openEditorsCmd = vscode.commands.registerCommand( "doom.showOpenEditors", () => { @@ -1494,14 +1452,8 @@ export function activate(context: vscode.ExtensionContext) { openEditorsCmd, allOpenEditorsCmd, findFileCmd, - findFileMoveDownCmd, - findFileMoveUpCmd, findFileInProjectCmd, showRecentProjectsCmd, - recentProjectsMoveDownCmd, - recentProjectsMoveUpCmd, - projectFileMoveDownCmd, - projectFileMoveUpCmd, sharedPanelViewProvider, new vscode.Disposable(() => { if (dashboardRefreshTimer) { diff --git a/src/panel/controller.ts b/src/panel/controller.ts new file mode 100644 index 0000000..ee82255 --- /dev/null +++ b/src/panel/controller.ts @@ -0,0 +1,213 @@ +import * as vscode from 'vscode'; + +/** + * Messages every Doom bottom-panel picker webview posts back to its controller. + * Panels may post additional `type` values (e.g. `findFile`'s `tab`); those are + * routed to {@link DoomWebviewController.onMessage}. + */ +export interface PanelWebviewMessage { + index?: number; + query?: string; + type: string; +} + +/** + * Shared lifecycle + message-dispatch base for Doom's bottom-panel pickers + * (find-file, project files, recent projects, open editors, search, which-key bindings). + * + * Owns the parts every panel re-implemented identically: + * - the `view` / `ready` / `viewDisposables` / `activeIndex` / `query` state, + * - `attachToView` / `detachFromView`, + * - the `resolveWebviewView` bootstrap (dispose old listeners → enable scripts → set HTML + * → stamp metadata → wire dispose/visibility/message listeners), + * - the render guard (`!view || !ready || !visible`), + * - the `handleMessage` dispatch skeleton (`ready | query | move | activate | close`), + * - `updateVisibilityContext`, and `close()`. + * + * Subclasses provide the five panel-specific members ({@link getHtml}, {@link filterItems}, + * {@link activateSelection}, {@link buildRenderState}, {@link itemCount}) and may override the + * optional hooks where their behavior genuinely differs. + */ +export abstract class DoomWebviewController { + protected activeIndex = 0; + protected query = ''; + protected ready = false; + protected view: vscode.WebviewView | undefined; + protected viewDisposables: vscode.Disposable[] = []; + + /** Context key flipped true while this panel is the visible bottom-panel mode. */ + protected abstract readonly visibleContextKey: string; + + // ----------------------------------------------------------------------- + // Public contract consumed by DoomSharedPanel / keybinding commands + // ----------------------------------------------------------------------- + + /** Wires the panel to an already-created WebviewView (e.g. on panel restore). */ + attachToView(webviewView: vscode.WebviewView): void { + this.resolveWebviewView(webviewView); + } + + /** Tears down listeners and clears the view ref without destroying the panel instance. */ + detachFromView(): void { + this.onDetach(); + this.viewDisposables.forEach((disposable) => disposable.dispose()); + this.viewDisposables = []; + this.view = undefined; + this.ready = false; + } + + /** + * Bootstraps a WebviewView: disposes any previous listeners, enables scripts, injects HTML, + * stamps metadata, then wires dispose/visibility/message (plus any panel-specific) listeners. + * Re-entrant — safe to call on view recycle. + */ + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.viewDisposables.forEach((disposable) => disposable.dispose()); + this.viewDisposables = []; + this.view = webviewView; + webviewView.webview.options = { enableScripts: true }; + webviewView.webview.html = this.getHtml(webviewView.webview); + this.updateViewMetadata(); + + this.viewDisposables.push( + webviewView.onDidDispose(() => { + if (this.view !== webviewView) { + return; + } + + this.onDispose(); + this.view = undefined; + this.ready = false; + void this.updateVisibilityContext(false); + }), + webviewView.onDidChangeVisibility(() => { + void this.updateVisibilityContext(webviewView.visible); + this.onVisibilityChanged(webviewView.visible); + }), + webviewView.webview.onDidReceiveMessage((message: PanelWebviewMessage) => { + void this.handleMessage(message); + }), + ...this.extraViewDisposables(webviewView), + ); + } + + // ----------------------------------------------------------------------- + // Message dispatch skeleton + // ----------------------------------------------------------------------- + + /** Standard panel message dispatch. Subclasses customize via the hooks rather than overriding this. */ + protected async handleMessage(message: PanelWebviewMessage): Promise { + switch (message.type) { + case 'ready': + this.ready = true; + this.onReady(); + this.render(); + await this.afterRender(true); + return; + case 'query': + await this.onQuery(message.query ?? ''); + return; + case 'move': + await this.onMove(message.index); + return; + case 'activate': + if (message.index !== undefined) { + this.activeIndex = this.clampIndex(message.index); + } + + await this.activateSelection(); + return; + case 'close': + await this.close(); + return; + default: + await this.onMessage(message); + return; + } + } + + /** Default query handling: store, re-filter, re-render. Overridden by `findFile` for path traversal. */ + protected async onQuery(query: string): Promise { + this.query = query; + this.filterItems(); + this.render(); + await this.afterRender(false); + } + + /** Default move handling: clamp into range, re-render. */ + protected async onMove(index: number | undefined): Promise { + if (this.itemCount === 0 || index === undefined) { + return; + } + + this.activeIndex = this.clampIndex(index); + this.render(); + await this.afterRender(false); + } + + // ----------------------------------------------------------------------- + // Render + context + close (shared) + // ----------------------------------------------------------------------- + + /** Pushes the current render payload to the webview. Guards against rendering before 'ready'/visible. */ + protected render(): void { + if (!this.view || !this.ready || !this.view.visible) { + return; + } + + void this.view.webview.postMessage({ type: 'render', state: this.buildRenderState() }); + } + + /** Syncs this panel's visibility context key so keybindings can scope to panel visibility. */ + protected async updateVisibilityContext(isVisible: boolean): Promise { + await vscode.commands.executeCommand('setContext', this.visibleContextKey, isVisible); + } + + /** Collapses the bottom panel. */ + protected async close(): Promise { + await vscode.commands.executeCommand('workbench.action.closePanel'); + } + + /** Clamps an index into the current result range (never negative, never past the last row). */ + protected clampIndex(index: number): number { + return Math.min(Math.max(index, 0), Math.max(this.itemCount - 1, 0)); + } + + // ----------------------------------------------------------------------- + // Members each panel must provide + // ----------------------------------------------------------------------- + + /** Number of currently selectable (filtered) rows — drives move/clamp/guards. */ + protected abstract get itemCount(): number; + /** Returns the full webview HTML for this panel. */ + protected abstract getHtml(webview: vscode.Webview): string; + /** Recomputes the filtered result list from the current `query`. */ + protected abstract filterItems(): void; + /** Handles Enter / click on the active row. */ + protected abstract activateSelection(): Promise; + /** Builds the render-state payload posted to the webview. */ + protected abstract buildRenderState(): object; + + // ----------------------------------------------------------------------- + // Optional hooks (no-op defaults) + // ----------------------------------------------------------------------- + + /** Stamps title/description onto the pane header. Called from `resolveWebviewView`. */ + protected updateViewMetadata(): void {} + /** Runs after `ready` is set, before the first render (e.g. seed items). */ + protected onReady(): void {} + /** Runs after every render. `initial` is true only for the post-'ready' render. */ + protected async afterRender(_initial: boolean): Promise {} + /** Extra cleanup when the panel is detached (runs before disposables are released). */ + protected onDetach(): void {} + /** Extra cleanup when the underlying view is disposed. */ + protected onDispose(): void {} + /** Reacts to the panel becoming visible/hidden (after the context key is synced). */ + protected onVisibilityChanged(_visible: boolean): void {} + /** Additional view-scoped listeners to register and auto-dispose with the view. */ + protected extraViewDisposables(_webviewView: vscode.WebviewView): vscode.Disposable[] { + return []; + } + /** Handles message types outside the common set (e.g. `findFile`'s `tab`). */ + protected async onMessage(_message: PanelWebviewMessage): Promise {} +} diff --git a/src/panel/helpers.ts b/src/panel/helpers.ts index 3b2dfad..963083e 100644 --- a/src/panel/helpers.ts +++ b/src/panel/helpers.ts @@ -152,33 +152,20 @@ export function formatRelativeTime(ms: number, now: number): string { return `${mon} ${d.getDate()} ${hh}:${mm}`; } +// --------------------------------------------------------------------------- +// Shared webview panel template (Finding 2) +// --------------------------------------------------------------------------- + /** - * Generates the full webview HTML for a two-column file-picker panel - * (project files / recent projects). + * Shared "chrome" CSS for every Doom picker panel: the `:root` custom-property block, + * the base element rules, and the `.shell` / `.promptbar` / `.status` / `.prompt` / + * `.input` / `.results` / `.empty` / `.match` rules. Panel-specific result-row layout + * (`.item` grid + cell rules) is appended after this via the builder's `layoutCss`. * - * Items posted to the webview via `render` state must have the shape: - * { index, path, matches, lastModified } + * Values that previously drifted slightly between panels (promptbar gap, status + * alignment, paddings) are intentionally unified to a single canonical value. */ -export function createFilePickerHtml(options: { - cspSource: string; - nonce: string; - title: string; -}): string { - const { cspSource, nonce, title } = options; - const csp = [ - "default-src 'none'", - `style-src ${cspSource} 'unsafe-inline'`, - `script-src 'nonce-${nonce}'`, - ].join('; '); - - return ` - - - - - - ${title} -
0/0
- - + +
@@ -354,7 +336,7 @@ export function createFilePickerHtml(options: { let items = []; let maxStatusWidth = 0; - // Renders text into container, wrapping fuzzy-matched char indices in . + // Renders text into container, wrapping matched char indices in . function appendHighlightedText(container, text, matches) { if (!matches || matches.length === 0) { container.textContent = text; @@ -385,7 +367,8 @@ export function createFilePickerHtml(options: { } } - // Full DOM reconcile from state. Skips overwriting the input if focused to avoid caret jump. + // Full DOM reconcile from state. Skips overwriting the input while focused (unless forced) + // to avoid caret jump. Status width only grows so the column never causes layout shift. function render(state) { items = state.items; document.title = state.title; @@ -399,42 +382,15 @@ export function createFilePickerHtml(options: { results.innerHTML = ''; empty.hidden = items.length > 0; - maxStatusWidth = Math.max(maxStatusWidth, state.statusWidthCh); - status.style.width = maxStatusWidth + 'ch'; + + if (state.statusWidthCh !== undefined) { + maxStatusWidth = Math.max(maxStatusWidth, state.statusWidthCh); + status.style.width = maxStatusWidth + 'ch'; + } status.textContent = state.statusLabel; items.forEach((item) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = item.index === state.activeIndex ? 'item active' : 'item'; - button.dataset.index = String(item.index); - - const pathEl = document.createElement('span'); - pathEl.className = 'item-path'; - appendHighlightedText(pathEl, item.path, item.matches); - - const permEl = document.createElement('span'); - if (item.host) { - permEl.className = 'item-host'; - permEl.textContent = '[' + item.host + ']'; - } else { - permEl.className = 'item-perm'; - permEl.textContent = item.permissions ?? ''; - } - - const sizeEl = document.createElement('span'); - sizeEl.className = 'item-size'; - sizeEl.textContent = item.size ?? ''; - - const timeEl = document.createElement('span'); - timeEl.className = 'item-time'; - timeEl.textContent = item.lastModified; - - button.append(pathEl, permEl, sizeEl, timeEl); - button.addEventListener('click', () => { - vscode.postMessage({ type: 'activate', index: item.index }); - }); - results.appendChild(button); +${renderItem} }); const activeButton = results.querySelector('[data-index="' + state.activeIndex + '"]'); @@ -457,68 +413,45 @@ export function createFilePickerHtml(options: { }); window.addEventListener('keydown', (event) => { - if (event.metaKey || event.altKey || event.ctrlKey) { - return; - } + const isCtrlMoveDown = event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'j'; + const isCtrlMoveUp = event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'k'; - if (event.key === 'Backspace') { - const val = query.value; - const selStart = query.selectionStart ?? val.length; - const selEnd = query.selectionEnd ?? val.length; - // When cursor is at end with no selection and preceding char is /, remove - // the whole path component back to the previous / (rapid dir traversal). - if (selStart === selEnd && selStart === val.length && val.length > 1 && val[val.length - 1] === '/') { - event.preventDefault(); - const withoutSlash = val.slice(0, -1); - const prevSlash = withoutSlash.lastIndexOf('/'); - const newVal = prevSlash >= 0 ? withoutSlash.slice(0, prevSlash + 1) : ''; - query.value = newVal; - vscode.postMessage({ type: 'query', query: newVal }); - return; - } + if (event.metaKey || event.altKey || (event.ctrlKey && !isCtrlMoveDown && !isCtrlMoveUp)) { + return; } - +${extraKeydown} if (event.key === 'Escape') { event.preventDefault(); vscode.postMessage({ type: 'close' }); return; } - if (event.key === 'ArrowDown') { - if (items.length === 0) { - return; - } - - event.preventDefault(); - const activeIndex = Number(results.querySelector('.item.active')?.dataset.index ?? '0'); - vscode.postMessage({ type: 'move', index: Math.min(activeIndex + 1, items.length - 1) }); - return; - } + const resultItems = items.filter((item) => item.type !== 'header'); - if (event.key === 'ArrowUp') { - if (items.length === 0) { + if (event.key === 'ArrowDown' || isCtrlMoveDown) { + if (resultItems.length === 0) { return; } event.preventDefault(); const activeIndex = Number(results.querySelector('.item.active')?.dataset.index ?? '0'); - vscode.postMessage({ type: 'move', index: Math.max(activeIndex - 1, 0) }); + vscode.postMessage({ type: 'move', index: Math.min(activeIndex + 1, resultItems.length - 1) }); return; } - if (event.key === 'Tab') { - if (items.length === 0) { + if (event.key === 'ArrowUp' || isCtrlMoveUp) { + if (resultItems.length === 0) { return; } event.preventDefault(); const activeIndex = Number(results.querySelector('.item.active')?.dataset.index ?? '0'); - vscode.postMessage({ type: 'tab', index: activeIndex }); + vscode.postMessage({ type: 'move', index: Math.max(activeIndex - 1, 0) }); return; } if (event.key === 'Enter') { - if (items.length === 0) { + if (resultItems.length === 0) { return; } @@ -532,4 +465,147 @@ export function createFilePickerHtml(options: { `; -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// File-picker template (find-file / project files / recent projects) +// --------------------------------------------------------------------------- + +/** Four-column result-row layout for the file pickers: path · perm/host · size · time. */ +const FILE_PICKER_LAYOUT_CSS = ` .item { + display: grid; + grid-template-columns: minmax(0, 55ch) 12ch 5ch 15ch; + align-items: center; + gap: 2ch; + flex: 0 0 auto; + padding: 0 10px; + border: none; + background: transparent; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + } + + .item-path { + overflow: hidden; + text-overflow: ellipsis; + } + + .item-host { + color: var(--accent); + white-space: nowrap; + } + + .item-perm { + color: var(--muted); + white-space: nowrap; + } + + .item-size { + color: var(--warning); + font-variant-numeric: tabular-nums; + white-space: nowrap; + text-align: right; + } + + .item-time { + color: var(--accent); + font-variant-numeric: tabular-nums; + white-space: nowrap; + text-align: right; + } + + .item.active { + background: var(--selected); + color: var(--selected-text); + outline: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); + outline-offset: -1px; + }`; + +/** Builds one file-picker row: highlighted path, host/permissions, size, relative time. */ +const FILE_PICKER_RENDER_ITEM = ` const button = document.createElement('button'); + button.type = 'button'; + button.className = item.index === state.activeIndex ? 'item active' : 'item'; + button.dataset.index = String(item.index); + + const pathEl = document.createElement('span'); + pathEl.className = 'item-path'; + appendHighlightedText(pathEl, item.path, item.matches); + + const permEl = document.createElement('span'); + if (item.host) { + permEl.className = 'item-host'; + permEl.textContent = '[' + item.host + ']'; + } else { + permEl.className = 'item-perm'; + permEl.textContent = item.permissions ?? ''; + } + + const sizeEl = document.createElement('span'); + sizeEl.className = 'item-size'; + sizeEl.textContent = item.size ?? ''; + + const timeEl = document.createElement('span'); + timeEl.className = 'item-time'; + timeEl.textContent = item.lastModified; + + button.append(pathEl, permEl, sizeEl, timeEl); + button.addEventListener('click', () => { + vscode.postMessage({ type: 'activate', index: item.index }); + }); + results.appendChild(button);`; + +/** + * Path-as-query key handling unique to the file pickers: Backspace deletes a whole + * path component back to the previous `/`, and Tab autocompletes to the active row. + */ +const FILE_PICKER_EXTRA_KEYDOWN = ` if (event.key === 'Backspace') { + const val = query.value; + const selStart = query.selectionStart ?? val.length; + const selEnd = query.selectionEnd ?? val.length; + // When cursor is at end with no selection and preceding char is /, remove + // the whole path component back to the previous / (rapid dir traversal). + if (selStart === selEnd && selStart === val.length && val.length > 1 && val[val.length - 1] === '/') { + event.preventDefault(); + const withoutSlash = val.slice(0, -1); + const prevSlash = withoutSlash.lastIndexOf('/'); + const newVal = prevSlash >= 0 ? withoutSlash.slice(0, prevSlash + 1) : ''; + query.value = newVal; + vscode.postMessage({ type: 'query', query: newVal }); + return; + } + } + + if (event.key === 'Tab') { + if (items.length === 0) { + return; + } + + event.preventDefault(); + const activeIndex = Number(results.querySelector('.item.active')?.dataset.index ?? '0'); + vscode.postMessage({ type: 'tab', index: activeIndex }); + return; + }`; + +/** + * Generates the full webview HTML for a two-column file-picker panel + * (find-file / project files / recent projects). Delegates to {@link createPanelHtml}. + * + * Items posted to the webview via `render` state must have the shape: + * { index, path, matches, lastModified, permissions?, host?, size? } + */ +export function createFilePickerHtml(options: { + cspSource: string; + nonce: string; + title: string; +}): string { + return createPanelHtml({ + ...options, + layoutCss: FILE_PICKER_LAYOUT_CSS, + renderItem: FILE_PICKER_RENDER_ITEM, + extraKeydown: FILE_PICKER_EXTRA_KEYDOWN, + }); +} diff --git a/src/search/findFile.ts b/src/search/findFile.ts index db34518..7d85495 100644 --- a/src/search/findFile.ts +++ b/src/search/findFile.ts @@ -1,6 +1,7 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; +import { DoomWebviewController, type PanelWebviewMessage } from '../panel/controller'; import { createFilePickerHtml, createNonce, formatRelativeTime, normalizePath, tildeCollapse, tildeExpand } from '../panel/helpers'; import { SelectionHistory } from './selectionHistory'; @@ -49,12 +50,6 @@ interface FindFileState { title: string; } -interface FindFileMessage { - index?: number; - query?: string; - type: 'activate' | 'close' | 'move' | 'query' | 'ready' | 'tab'; -} - // --------------------------------------------------------------------------- // Panel // --------------------------------------------------------------------------- @@ -71,12 +66,15 @@ interface FindFileMessage { * appends its name to the query, advancing into the directory. Backspacing * past a `/` retreats into the parent. */ -export class DoomFindFilePanel { +export class DoomFindFilePanel extends DoomWebviewController { static readonly visibleContextKey = 'doom.findFileVisible'; - constructor(private readonly history: SelectionHistory) {} + protected readonly visibleContextKey = DoomFindFilePanel.visibleContextKey; + + constructor(private readonly history: SelectionHistory) { + super(); + } - private activeIndex = 0; private allItems: FindFileItem[] = []; private baseAuthority = ''; private baseScheme = 'file'; @@ -85,10 +83,6 @@ export class DoomFindFilePanel { private filteredItems: FindFileItem[] = []; private forceQueryUpdate = false; private loading = false; - private query = ''; - private ready = false; - private view: vscode.WebviewView | undefined; - private viewDisposables: vscode.Disposable[] = []; /** * Sets the starting directory. `startDir` should be an absolute path @@ -118,36 +112,21 @@ export class DoomFindFilePanel { this.render(); } - attachToView(webviewView: vscode.WebviewView): void { - this.resolveWebviewView(webviewView); + protected get itemCount(): number { + return this.filteredItems.length; } - detachFromView(): void { - this.viewDisposables.forEach((d) => d.dispose()); - this.viewDisposables = []; - this.view = undefined; - this.ready = false; - } - - moveSelection(delta: number): void { - if (!this.view?.visible || this.filteredItems.length === 0) { + /** Stamps the pane header. */ + protected updateViewMetadata(): void { + if (!this.view) { return; } - const nextIndex = Math.min( - Math.max(this.activeIndex + delta, 0), - this.filteredItems.length - 1 - ); - - if (nextIndex === this.activeIndex) { - return; - } - - this.activeIndex = nextIndex; - this.render(); + this.view.title = 'Find File'; + this.view.description = undefined; } - async activateSelection(): Promise { + protected async activateSelection(): Promise { if (!this.view?.visible || this.filteredItems.length === 0) { return; } @@ -188,32 +167,6 @@ export class DoomFindFilePanel { await vscode.window.showTextDocument(document, { preview: false, preserveFocus: false }); } - resolveWebviewView(webviewView: vscode.WebviewView): void { - this.viewDisposables.forEach((d) => d.dispose()); - this.viewDisposables = []; - this.view = webviewView; - webviewView.webview.options = { enableScripts: true }; - webviewView.webview.html = this.getHtml(webviewView.webview); - webviewView.title = 'Find File'; - webviewView.description = undefined; - - this.viewDisposables.push( - webviewView.onDidDispose(() => { - if (this.view === webviewView) { - this.view = undefined; - this.ready = false; - void this.updateVisibilityContext(false); - } - }), - webviewView.onDidChangeVisibility(() => { - void this.updateVisibilityContext(webviewView.visible); - }), - webviewView.webview.onDidReceiveMessage((message: FindFileMessage) => { - void this.handleMessage(message); - }) - ); - } - /** * Parses `this.query` into dir + filter. Reloads directory if dir changed; * otherwise just re-filters and re-renders. @@ -292,7 +245,7 @@ export class DoomFindFilePanel { this.allItems = [...dirs, ...files]; } - private filterItems(): void { + protected filterItems(): void { this.activeIndex = 0; const q = this.filter.toLowerCase(); @@ -304,64 +257,42 @@ export class DoomFindFilePanel { this.filteredItems = this.allItems.filter((item) => item.searchText.includes(q)); } - private async handleMessage(message: FindFileMessage): Promise { - switch (message.type) { - case 'ready': - this.ready = true; - this.render(); - return; - case 'query': { - const expanded = tildeExpand(message.query ?? ''); - if (!expanded && this.query === normalizePath(os.homedir()) + '/') { - this.query = normalizePath(path.dirname(normalizePath(os.homedir()))) + '/'; - this.forceQueryUpdate = true; - } else { - this.query = expanded; - } - await this.applyQueryChange(); - return; - } - case 'move': { - if (this.filteredItems.length === 0 || message.index === undefined) { - return; - } - this.activeIndex = message.index; - this.render(); - return; - } - case 'activate': { - if (message.index !== undefined) { - this.activeIndex = message.index; - } - await this.activateSelection(); - return; - } - case 'tab': { - if (this.filteredItems.length === 0 || message.index === undefined) { - return; - } - const tabItem = this.filteredItems[message.index]; - if (!tabItem) { - return; - } - this.query = tabItem.isDir ? tabItem.fsPath + '/' : tabItem.fsPath; + /** + * The query input IS the path being typed: expand `~`, retreat past the home dir on + * a leading delete, then re-parse into dir + filter via `applyQueryChange`. + */ + protected async onQuery(query: string): Promise { + const expanded = tildeExpand(query); + if (!expanded && this.query === normalizePath(os.homedir()) + '/') { + this.query = normalizePath(path.dirname(normalizePath(os.homedir()))) + '/'; this.forceQueryUpdate = true; - await this.applyQueryChange(); - return; + } else { + this.query = expanded; } - case 'close': - await this.close(); + await this.applyQueryChange(); + } + + /** Handles Tab autocompletion: replaces the query with the active item's full path. */ + protected async onMessage(message: PanelWebviewMessage): Promise { + if (message.type !== 'tab') { return; - default: + } + + if (this.filteredItems.length === 0 || message.index === undefined) { return; } - } - private render(): void { - if (!this.view || !this.ready || !this.view.visible) { + const tabItem = this.filteredItems[message.index]; + if (!tabItem) { return; } + this.query = tabItem.isDir ? tabItem.fsPath + '/' : tabItem.fsPath; + this.forceQueryUpdate = true; + await this.applyQueryChange(); + } + + protected buildRenderState(): FindFileState { const activeIndex = this.filteredItems.length === 0 ? 0 : Math.min(this.activeIndex, this.filteredItems.length - 1); @@ -383,7 +314,7 @@ export class DoomFindFilePanel { const forceQuery = this.forceQueryUpdate; this.forceQueryUpdate = false; - const state: FindFileState = { + return { activeIndex, emptyText: this.loading ? 'Reading directory...' : 'No matches.', forceQuery, @@ -395,8 +326,6 @@ export class DoomFindFilePanel { statusWidthCh: this.getStatusWidthCh(), title: 'Find File', }; - - void this.view.webview.postMessage({ type: 'render', state }); } private getStatusLabel(): string { @@ -410,15 +339,7 @@ export class DoomFindFilePanel { return digits * 2 + 1; } - private async updateVisibilityContext(isVisible: boolean): Promise { - await vscode.commands.executeCommand('setContext', DoomFindFilePanel.visibleContextKey, isVisible); - } - - private async close(): Promise { - await vscode.commands.executeCommand('workbench.action.closePanel'); - } - - private getHtml(webview: vscode.Webview): string { + protected getHtml(webview: vscode.Webview): string { return createFilePickerHtml({ cspSource: webview.cspSource, nonce: createNonce(), diff --git a/src/search/projectFile.ts b/src/search/projectFile.ts index dc6f2ea..2b43fd5 100644 --- a/src/search/projectFile.ts +++ b/src/search/projectFile.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { DoomWebviewController } from '../panel/controller'; import { createFilePickerHtml, createNonce, formatFileSize, formatPermissions, formatRelativeTime, orderlessMatch } from '../panel/helpers'; import { SelectionHistory } from './selectionHistory'; @@ -46,12 +47,6 @@ interface ProjectFileState { title: string; } -interface ProjectFileMessage { - index?: number; - query?: string; - type: 'activate' | 'close' | 'move' | 'query' | 'ready'; -} - // --------------------------------------------------------------------------- // File listing // --------------------------------------------------------------------------- @@ -88,20 +83,19 @@ async function listProjectFiles(rootUri: vscode.Uri, loadId: number, loadSequenc * Results are sorted by fuzzy score when a query is present, preserving the MRU * ordering for equal-score items (stable sort is guaranteed by V8). */ -export class DoomProjectFilePanel { +export class DoomProjectFilePanel extends DoomWebviewController { static readonly visibleContextKey = 'doom.projectFileVisible'; - constructor(private readonly history: SelectionHistory) {} + protected readonly visibleContextKey = DoomProjectFilePanel.visibleContextKey; + + constructor(private readonly history: SelectionHistory) { + super(); + } - private activeIndex = 0; private allItems: ProjectFileItem[] = []; private filteredItems: ProjectFileMatch[] = []; private loading = false; private loadSequence = 0; - private query = ''; - private ready = false; - private view: vscode.WebviewView | undefined; - private viewDisposables: vscode.Disposable[] = []; private workspaceCache: ProjectFileItem[] | undefined; private workspaceCacheWatcher: vscode.FileSystemWatcher | undefined; @@ -121,40 +115,41 @@ export class DoomProjectFilePanel { await this.loadProjectItems(); } - /** Wires the panel to an already-created WebviewView. */ - attachToView(webviewView: vscode.WebviewView): void { - this.resolveWebviewView(webviewView); + protected get itemCount(): number { + return this.filteredItems.length; } - /** Tears down listeners and clears the view ref. */ - detachFromView(): void { - this.viewDisposables.forEach((d) => d.dispose()); - this.viewDisposables = []; - this.view = undefined; - this.ready = false; - } - - /** Moves the active result by `delta` rows. No-op at list boundaries. */ - moveSelection(delta: number): void { - if (!this.view?.visible || this.filteredItems.length === 0) { + /** Stamps the pane header. */ + protected updateViewMetadata(): void { + if (!this.view) { return; } - const nextIndex = Math.min( - Math.max(this.activeIndex + delta, 0), - this.filteredItems.length - 1 - ); + this.view.title = 'Find File'; + this.view.description = `Project: ${this.getWorkspaceLabel()}`; + } + + /** Seeds open-tab ordering before the first render once items are loaded. */ + protected onReady(): void { + if (!this.loading) { + this.seedItems(); + } + } - if (nextIndex === this.activeIndex) { + /** Re-seeds from the current tab layout each time the panel is revealed. */ + protected onVisibilityChanged(visible: boolean): void { + if (!visible) { return; } - this.activeIndex = nextIndex; + this.query = ''; + this.activeIndex = 0; + this.seedItems(); this.render(); } /** Opens the active result and closes the panel. */ - async activateSelection(): Promise { + protected async activateSelection(): Promise { if (!this.view?.visible || this.filteredItems.length === 0) { return; } @@ -191,47 +186,6 @@ export class DoomProjectFilePanel { await vscode.window.showTextDocument(document, { preview: false, preserveFocus: false }); } - /** - * Bootstraps the WebviewView: injects HTML, wires dispose/visibility/message listeners. - * Re-entrant — cleans up previous listeners first. - */ - resolveWebviewView(webviewView: vscode.WebviewView): void { - this.viewDisposables.forEach((d) => d.dispose()); - this.viewDisposables = []; - this.view = webviewView; - webviewView.webview.options = { enableScripts: true }; - webviewView.webview.html = this.getHtml(webviewView.webview); - webviewView.title = 'Find File'; - webviewView.description = `Project: ${this.getWorkspaceLabel()}`; - - this.viewDisposables.push( - webviewView.onDidDispose(() => { - if (this.view === webviewView) { - this.view = undefined; - this.ready = false; - void this.updateVisibilityContext(false); - } - }), - webviewView.onDidChangeVisibility(() => { - void this.updateVisibilityContext(webviewView.visible); - if (webviewView.visible) { - this.query = ''; - this.activeIndex = 0; - this.seedItems(); - this.render(); - } - }), - webviewView.webview.onDidReceiveMessage((message: ProjectFileMessage) => { - void this.handleMessage(message); - }) - ); - } - - /** Syncs the `doom.projectFileVisible` context key so keybindings can scope to panel visibility. */ - private async updateVisibilityContext(isVisible: boolean): Promise { - await vscode.commands.executeCommand('setContext', DoomProjectFilePanel.visibleContextKey, isVisible); - } - /** * Discovers all workspace files, caches them, and triggers a render. * Subsequent calls reuse the cache; a FileSystemWatcher invalidates it on changes. @@ -350,7 +304,7 @@ export class DoomProjectFilePanel { * Empty query: shows all items preserving MRU order (open tabs first, then remaining by mtime desc). * Non-empty query: sorts by fuzzy score (higher = better); mtime breaks ties. */ - private filterItems(): void { + protected filterItems(): void { this.activeIndex = 0; const query = this.query.trim().toLowerCase(); @@ -383,52 +337,8 @@ export class DoomProjectFilePanel { .slice(0, MAX_RESULTS); } - /** Dispatches webview messages. */ - private async handleMessage(message: ProjectFileMessage): Promise { - switch (message.type) { - case 'ready': - this.ready = true; - if (!this.loading) { - this.seedItems(); - } - this.render(); - return; - case 'query': - this.query = message.query ?? ''; - this.filterItems(); - this.render(); - return; - case 'move': { - if (this.filteredItems.length === 0 || message.index === undefined) { - return; - } - - this.activeIndex = message.index; - this.render(); - return; - } - case 'activate': { - if (message.index !== undefined) { - this.activeIndex = message.index; - } - - await this.activateSelection(); - return; - } - case 'close': - await this.close(); - return; - default: - return; - } - } - - /** Builds the full ProjectFileState and pushes it to the webview. Guards against rendering before 'ready'. */ - private render(): void { - if (!this.view || !this.ready || !this.view.visible) { - return; - } - + /** Builds the full ProjectFileState. Clamps `activeIndex` into range first. */ + protected buildRenderState(): ProjectFileState { const activeIndex = this.filteredItems.length === 0 ? 0 : Math.min(this.activeIndex, this.filteredItems.length - 1); @@ -446,7 +356,7 @@ export class DoomProjectFilePanel { type: 'result', })); - const state: ProjectFileState = { + return { activeIndex, emptyText: this.loading ? 'Loading project files...' : 'No matches.', items, @@ -457,8 +367,6 @@ export class DoomProjectFilePanel { statusWidthCh: this.getStatusWidthCh(), title: 'Find File in Project', }; - - void this.view.webview.postMessage({ type: 'render', state }); } private getStatusLabel(): string { @@ -476,13 +384,8 @@ export class DoomProjectFilePanel { return vscode.workspace.name ?? 'workspace'; } - /** Collapses the bottom panel — keeps the webview alive so cache survives the next open. */ - private async close(): Promise { - await vscode.commands.executeCommand('workbench.action.closePanel'); - } - /** Generates the webview HTML using the shared file-picker template. */ - private getHtml(webview: vscode.Webview): string { + protected getHtml(webview: vscode.Webview): string { return createFilePickerHtml({ cspSource: webview.cspSource, nonce: createNonce(), diff --git a/src/search/recentProjects.ts b/src/search/recentProjects.ts index af0a6f0..2ef7a54 100644 --- a/src/search/recentProjects.ts +++ b/src/search/recentProjects.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; +import { DoomWebviewController } from '../panel/controller'; import { createFilePickerHtml, createNonce, formatFileSize, formatPermissions, formatRelativeTime, orderlessMatch, tildeCollapse } from '../panel/helpers'; // --------------------------------------------------------------------------- @@ -52,12 +53,6 @@ interface RecentProjectState { title: string; } -interface RecentProjectMessage { - index?: number; - query?: string; - type: 'activate' | 'close' | 'move' | 'query' | 'ready'; -} - // --------------------------------------------------------------------------- // Loader (exported for reuse outside this panel) // --------------------------------------------------------------------------- @@ -173,10 +168,11 @@ export async function getRecentProjects(): Promise { * Doom panels so it can be dropped into any `DoomSharedPanel` slot. * Selecting an entry opens the workspace in the current window. */ -export class DoomRecentProjectsPanel { +export class DoomRecentProjectsPanel extends DoomWebviewController { static readonly visibleContextKey = 'doom.recentProjectsVisible'; - private activeIndex = 0; + protected readonly visibleContextKey = DoomRecentProjectsPanel.visibleContextKey; + private allItems: RecentProjectItem[] = []; private filteredItems: RecentProjectMatch[] = []; private loading = false; @@ -185,16 +181,10 @@ export class DoomRecentProjectsPanel { * Used by the "spc spc with no workspace" flow to chain into a file picker. */ private onProjectSelected: ((item: RecentProjectItem) => Promise) | undefined; - private query = ''; - private ready = false; - private view: vscode.WebviewView | undefined; - private viewDisposables: vscode.Disposable[] = []; /** * Resets state. * Pass `onProjectSelected` to intercept selection instead of opening the folder. - * Returns void (not boolean) because there is no precondition to check; the shared - * panel interface calls prepareShow() for its boolean, but this panel always proceeds. */ prepareShow(onProjectSelected?: (item: RecentProjectItem) => Promise): void { this.onProjectSelected = onProjectSelected; @@ -218,40 +208,22 @@ export class DoomRecentProjectsPanel { this.render(); } - /** Wires the panel to an already-created WebviewView. */ - attachToView(webviewView: vscode.WebviewView): void { - this.resolveWebviewView(webviewView); - } - - /** Tears down listeners and clears the view ref. */ - detachFromView(): void { - this.viewDisposables.forEach((d) => d.dispose()); - this.viewDisposables = []; - this.view = undefined; - this.ready = false; + protected get itemCount(): number { + return this.filteredItems.length; } - /** Moves the active result by `delta` rows. No-op at list boundaries. */ - moveSelection(delta: number): void { - if (!this.view?.visible || this.filteredItems.length === 0) { - return; - } - - const nextIndex = Math.min( - Math.max(this.activeIndex + delta, 0), - this.filteredItems.length - 1 - ); - - if (nextIndex === this.activeIndex) { + /** Stamps the pane header. */ + protected updateViewMetadata(): void { + if (!this.view) { return; } - this.activeIndex = nextIndex; - this.render(); + this.view.title = 'Open Recent'; + this.view.description = 'Select a workspace'; } /** Opens the selected workspace, or calls `onProjectSelected` if set. */ - async activateSelection(): Promise { + protected async activateSelection(): Promise { if (!this.view?.visible || this.filteredItems.length === 0) { return; } @@ -270,47 +242,12 @@ export class DoomRecentProjectsPanel { } } - /** - * Bootstraps the WebviewView: injects HTML, wires dispose/visibility/message listeners. - * Re-entrant — cleans up previous listeners first. - */ - resolveWebviewView(webviewView: vscode.WebviewView): void { - this.viewDisposables.forEach((d) => d.dispose()); - this.viewDisposables = []; - this.view = webviewView; - webviewView.webview.options = { enableScripts: true }; - webviewView.webview.html = this.getHtml(webviewView.webview); - webviewView.title = 'Open Recent'; - webviewView.description = 'Select a workspace'; - - this.viewDisposables.push( - webviewView.onDidDispose(() => { - if (this.view === webviewView) { - this.view = undefined; - this.ready = false; - void this.updateVisibilityContext(false); - } - }), - webviewView.onDidChangeVisibility(() => { - void this.updateVisibilityContext(webviewView.visible); - }), - webviewView.webview.onDidReceiveMessage((message: RecentProjectMessage) => { - void this.handleMessage(message); - }) - ); - } - - /** Syncs the `doom.recentProjectsVisible` context key. */ - private async updateVisibilityContext(isVisible: boolean): Promise { - await vscode.commands.executeCommand('setContext', DoomRecentProjectsPanel.visibleContextKey, isVisible); - } - /** * Orderless-filters `allItems` (already in MRU order). * Runs against `searchText` (label + path); splits match indices into separate * label and path arrays so both portions can be highlighted independently. */ - private filterItems(): void { + protected filterItems(): void { this.activeIndex = 0; const query = this.query.trim().toLowerCase(); @@ -344,49 +281,8 @@ export class DoomRecentProjectsPanel { .sort((a, b) => b.score - a.score); } - /** Dispatches webview messages. */ - private async handleMessage(message: RecentProjectMessage): Promise { - switch (message.type) { - case 'ready': - this.ready = true; - this.render(); - return; - case 'query': - this.query = message.query ?? ''; - this.filterItems(); - this.render(); - return; - case 'move': { - if (this.filteredItems.length === 0 || message.index === undefined) { - return; - } - - this.activeIndex = message.index; - this.render(); - return; - } - case 'activate': { - if (message.index !== undefined) { - this.activeIndex = message.index; - } - - await this.activateSelection(); - return; - } - case 'close': - await this.close(); - return; - default: - return; - } - } - - /** Builds the full state and pushes it to the webview. Guards against rendering before 'ready'. */ - private render(): void { - if (!this.view || !this.ready || !this.view.visible) { - return; - } - + /** Builds the full render state. Clamps `activeIndex` into range first. */ + protected buildRenderState(): RecentProjectState { const activeIndex = this.filteredItems.length === 0 ? 0 : Math.min(this.activeIndex, this.filteredItems.length - 1); @@ -405,7 +301,7 @@ export class DoomRecentProjectsPanel { type: 'result', })); - const state: RecentProjectState = { + return { activeIndex, emptyText: this.loading ? 'Loading recent projects...' : 'No recent projects found.', items, @@ -416,8 +312,6 @@ export class DoomRecentProjectsPanel { statusWidthCh: this.getStatusWidthCh(), title: 'Open Recent Project', }; - - void this.view.webview.postMessage({ type: 'render', state }); } private getStatusLabel(): string { @@ -431,13 +325,8 @@ export class DoomRecentProjectsPanel { return digits * 2 + 1; } - /** Collapses the bottom panel. */ - private async close(): Promise { - await vscode.commands.executeCommand('workbench.action.closePanel'); - } - /** Generates the webview HTML using the shared file-picker template. */ - private getHtml(webview: vscode.Webview): string { + protected getHtml(webview: vscode.Webview): string { return createFilePickerHtml({ cspSource: webview.cspSource, nonce: createNonce(), diff --git a/src/search/search.ts b/src/search/search.ts index 393c561..9b85cf9 100644 --- a/src/search/search.ts +++ b/src/search/search.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; -import { createNonce, substringMatch } from '../panel/helpers'; +import { DoomWebviewController } from '../panel/controller'; +import { createNonce, createPanelHtml, substringMatch } from '../panel/helpers'; const MAX_RESULTS = 200; @@ -49,12 +50,6 @@ interface SearchState { title: string; } -interface SearchMessage { - index?: number; - query?: string; - type: 'activate' | 'close' | 'move' | 'query' | 'ready'; -} - interface SearchOptions { notifyWhenMissing?: boolean; resetQuery?: boolean; @@ -62,25 +57,129 @@ interface SearchOptions { type SearchMode = 'editor' | 'workspace'; -export class DoomSearchPanel { +/** Line-number + content layout, plus the workspace file-group header rows. */ +const SEARCH_LAYOUT_CSS = ` .item { + display: grid; + grid-template-columns: minmax(4ch, auto) 1fr; + align-items: baseline; + gap: 12px; + flex: 0 0 auto; + padding: 0 10px; + border: none; + background: transparent; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .item.active { + color: inherit; + } + + .group { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px 0; + color: var(--muted); + font-style: italic; + } + + .group::before { + content: ''; + flex: 0 0 auto; + width: 5ch; + border-top: 1px solid var(--border); + opacity: 0.8; + } + + .group::after { + content: ''; + flex: 1 1 auto; + border-top: 1px solid var(--border); + opacity: 0.8; + } + + .group-label { + white-space: nowrap; + color: color-mix(in srgb, var(--accent) 65%, var(--text)); + } + + .line { + color: var(--muted); + font-variant-numeric: tabular-nums; + justify-self: end; + text-align: right; + opacity: 0.95; + } + + .content { + display: block; + min-width: 0; + padding-left: 5px; + overflow: hidden; + text-overflow: ellipsis; + } + + .item.active .content { + background: var(--selected); + color: var(--selected-text); + outline: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); + outline-offset: -1px; + }`; + +/** Builds a workspace file-group header or a line-number + highlighted-content row. */ +const SEARCH_RENDER_ITEM = ` if (item.type === 'header') { + const header = document.createElement('div'); + header.className = 'group'; + + const label = document.createElement('span'); + label.className = 'group-label'; + label.textContent = item.fileLabel; + header.append(label); + results.appendChild(header); + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = item.index === state.activeIndex ? 'item active' : 'item'; + button.dataset.index = String(item.index); + + const line = document.createElement('span'); + line.className = 'line'; + line.textContent = item.lineLabel; + + const content = document.createElement('span'); + content.className = 'content'; + appendHighlightedText(content, item.text, item.matches); + + button.append(line, content); + button.addEventListener('click', () => { + vscode.postMessage({ type: 'activate', index: item.index }); + }); + results.appendChild(button);`; + +export class DoomSearchPanel extends DoomWebviewController { static readonly visibleContextKey = 'doom.fuzzySearchVisible'; + protected readonly visibleContextKey = DoomSearchPanel.visibleContextKey; + private static readonly workspaceExcludeGlob = '**/{.git,node_modules,out,dist,coverage,build,.next}/**'; private static readonly workspaceFileSizeLimit = 1024 * 1024; private accepted = false; - private activeIndex = 0; private currentItems: SearchItem[] = []; private filteredItems: SearchMatch[] = []; private loadSequence = 0; private loading = false; private mode: SearchMode = 'editor'; - private query = ''; - private ready = false; private startingSelection: vscode.Selection | undefined; private targetEditor: vscode.TextEditor | undefined; - private view: vscode.WebviewView | undefined; - private viewDisposables: vscode.Disposable[] = []; private workspaceCache: SearchItem[] | undefined; private workspaceCacheWatcher: vscode.FileSystemWatcher | undefined; @@ -101,44 +200,12 @@ export class DoomSearchPanel { await this.loadWorkspaceItems(); } - /** Wires the panel to an already-created WebviewView (e.g. on sidebar restore). */ - attachToView(webviewView: vscode.WebviewView): void { - this.resolveWebviewView(webviewView); - } - - /** Tears down listeners, restores the original editor selection if search was cancelled, and clears the view ref. */ - detachFromView(): void { - this.restoreSelectionIfNeeded(); - this.viewDisposables.forEach((disposable) => disposable.dispose()); - this.viewDisposables = []; - this.view = undefined; - this.ready = false; - } - - /** Moves the active result by `delta` rows and live-previews the line in editor mode. No-op at list boundaries. */ - moveSelection(delta: number): void { - if (!this.view?.visible || this.filteredItems.length === 0) { - return; - } - - const nextIndex = Math.min( - Math.max(this.activeIndex + delta, 0), - this.filteredItems.length - 1 - ); - - if (nextIndex === this.activeIndex) { - return; - } - - this.activeIndex = nextIndex; - if (this.mode === 'editor') { - this.revealEditorLine(this.filteredItems[nextIndex].item.line); - } - this.render(); + protected get itemCount(): number { + return this.filteredItems.length; } /** Confirms the active result: opens the file/line and closes the panel. Sets `accepted` to suppress selection restore. */ - async activateSelection(): Promise { + protected async activateSelection(): Promise { if (!this.view?.visible || this.filteredItems.length === 0) { return; } @@ -157,47 +224,33 @@ export class DoomSearchPanel { await this.close(); } - /** - * Bootstraps a WebviewView: injects HTML, wires dispose/visibility/message listeners. - * Re-entrant — cleans up previous listeners first, so safe to call on view recycle. - */ - resolveWebviewView(webviewView: vscode.WebviewView): void { - this.viewDisposables.forEach((disposable) => disposable.dispose()); - this.viewDisposables = []; - this.view = webviewView; - webviewView.webview.options = { - enableScripts: true, - }; - webviewView.webview.html = this.getHtml(webviewView.webview); - this.updateViewMetadata(); + /** Live-previews the active line in editor mode after a query/move render. Skips the initial reveal. */ + protected async afterRender(initial: boolean): Promise { + if (initial || this.mode !== 'editor' || this.filteredItems.length === 0) { + return; + } - this.viewDisposables.push( - webviewView.onDidDispose(() => { - if (this.view === webviewView) { - this.restoreSelectionIfNeeded(); - this.view = undefined; - this.ready = false; - void this.updateVisibilityContext(false); - } - }), - webviewView.onDidChangeVisibility(() => { - void this.updateVisibilityContext(webviewView.visible); - if (webviewView.visible) { - void this.refreshVisibleSearch(); - return; - } + this.revealEditorLine(this.filteredItems[this.activeIndex].item.line); + } - this.restoreSelectionIfNeeded(); - }), - webviewView.webview.onDidReceiveMessage((message: SearchMessage) => { - void this.handleMessage(message); - }) - ); + /** Restores the pre-search selection when the panel is detached. */ + protected onDetach(): void { + this.restoreSelectionIfNeeded(); } - /** Syncs the `doom.fuzzySearchVisible` context key so keybindings can scope to panel visibility. */ - private async updateVisibilityContext(isVisible: boolean): Promise { - await vscode.commands.executeCommand('setContext', DoomSearchPanel.visibleContextKey, isVisible); + /** Restores the pre-search selection when the underlying view is disposed. */ + protected onDispose(): void { + this.restoreSelectionIfNeeded(); + } + + /** Re-initializes on reveal, or restores the pre-search selection on hide. */ + protected onVisibilityChanged(visible: boolean): void { + if (visible) { + void this.refreshVisibleSearch(); + return; + } + + this.restoreSelectionIfNeeded(); } /** Re-initializes and re-renders when the panel becomes visible again — handles both modes. */ @@ -221,7 +274,7 @@ export class DoomSearchPanel { } /** Stamps mode-appropriate title and description onto the sidebar pane header. */ - private updateViewMetadata(): void { + protected updateViewMetadata(): void { if (!this.view) { return; } @@ -423,7 +476,7 @@ export class DoomSearchPanel { * Editor mode: shows first MAX_RESULTS lines unranked for queries < 2 chars, then sorts by line number. * Workspace mode: shows nothing until 2+ chars are typed, then groups by file via `groupWorkspaceMatches`. */ - private filterItems(): void { + protected filterItems(): void { this.activeIndex = 0; const query = this.query.trim().toLowerCase(); @@ -493,53 +546,6 @@ export class DoomSearchPanel { .flatMap((group) => group.matches.sort((left, right) => left.item.line - right.item.line)); } - /** Dispatches webview messages. `query` also triggers a live editor preview of the first result. */ - private async handleMessage(message: SearchMessage): Promise { - switch (message.type) { - case 'ready': - this.ready = true; - this.render(); - return; - case 'query': - this.query = message.query ?? ''; - this.filterItems(); - this.render(); - if (this.mode === 'editor' && this.filteredItems.length > 0) { - this.revealEditorLine(this.filteredItems[0].item.line); - } - return; - case 'move': { - if (this.filteredItems.length === 0 || message.index === undefined) { - return; - } - - const item = this.filteredItems[message.index]; - if (!item) { - return; - } - - this.activeIndex = message.index; - if (this.mode === 'editor') { - this.revealEditorLine(item.item.line); - } - this.render(); - return; - } - case 'activate': { - if (message.index !== undefined) { - this.activeIndex = message.index; - } - await this.activateSelection(); - return; - } - case 'close': - await this.close(); - return; - default: - return; - } - } - /** Scrolls the target editor to `line` and moves the cursor there for live preview during navigation. */ private revealEditorLine(line: number): void { const editor = this.targetEditor; @@ -570,18 +576,14 @@ export class DoomSearchPanel { editor.selection = new vscode.Selection(position, position); } - /** Builds the full SearchState and pushes it to the webview. Guards against rendering before 'ready'. */ - private render(): void { - if (!this.view || !this.ready || !this.view.visible) { - return; - } - + /** Builds the full SearchState. Clamps `activeIndex` into range first. */ + protected buildRenderState(): SearchState { const activeIndex = this.filteredItems.length === 0 ? 0 : Math.min(this.activeIndex, this.filteredItems.length - 1); this.activeIndex = activeIndex; - const state: SearchState = { + return { activeIndex, emptyText: this.getEmptyText(), items: this.toRenderItems(), @@ -596,11 +598,6 @@ export class DoomSearchPanel { statusWidthCh: this.getStatusWidthCh(), title: this.mode === 'workspace' ? 'Project Search' : 'Fuzzy Search', }; - - void this.view.webview.postMessage({ - type: 'render', - state, - }); } /** Converts filtered matches to the render model. In workspace mode inserts file header rows on group boundaries. */ @@ -698,387 +695,13 @@ export class DoomSearchPanel { this.targetEditor.selection = this.startingSelection; } - /** Collapses the bottom panel — keeps the webview alive so state survives the next open. */ - private async close(): Promise { - await vscode.commands.executeCommand('workbench.action.closePanel'); - } - - /** - * Generates the full webview HTML. Nonce-locked CSP prevents script injection. - * The embedded script owns all DOM interaction and communicates exclusively via postMessage. - */ - private getHtml(webview: vscode.Webview): string { - const nonce = createNonce(); - const csp = [ - "default-src 'none'", - `style-src ${webview.cspSource} 'unsafe-inline'`, - `script-src 'nonce-${nonce}'`, - ].join('; '); - - return ` - - - - - - Fuzzy Search - - - -
-
-
0/0
- - -
-
- -
- - -`; } } diff --git a/src/whichkey/bindingsPanel.ts b/src/whichkey/bindingsPanel.ts index e0395d0..b2afe49 100644 --- a/src/whichkey/bindingsPanel.ts +++ b/src/whichkey/bindingsPanel.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; -import { createNonce, substringMatch } from '../panel/helpers'; +import { DoomWebviewController } from '../panel/controller'; +import { createNonce, createPanelHtml, substringMatch } from '../panel/helpers'; import { executeWhichKeyBindingCommands } from './bindings'; import { getFlattenedWhichKeyBindings, @@ -34,26 +35,77 @@ interface WhichKeyBindingsState { title: string; } -interface WhichKeyBindingsMessage { - index?: number; - query?: string; - type: 'activate' | 'close' | 'move' | 'query' | 'ready'; -} - // --------------------------------------------------------------------------- // Which-key bindings panel // --------------------------------------------------------------------------- -export class DoomWhichKeyBindingsPanel { +/** Three-column result-row layout: highlighted key path · binding name · command detail. */ +const BINDINGS_LAYOUT_CSS = ` .item { + display: grid; + grid-template-columns: minmax(16ch, 24ch) minmax(18ch, 26ch) minmax(0, 1fr); + gap: 2ch; + align-items: center; + min-height: var(--line-height); + padding: 0 10px; + border: none; + background: transparent; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; + } + + .item.active { + background: var(--selected); + outline: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); + outline-offset: -1px; + } + + .path, + .name, + .detail { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .name, + .detail { + color: var(--muted); + }`; + +/** Builds one bindings row: highlighted key path, binding name, command detail. */ +const BINDINGS_RENDER_ITEM = ` const button = document.createElement('button'); + button.type = 'button'; + button.className = item.index === state.activeIndex ? 'item active' : 'item'; + button.dataset.index = String(item.index); + + const path = document.createElement('span'); + path.className = 'path'; + appendHighlightedText(path, item.path, item.matches); + + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = item.name; + + const detail = document.createElement('span'); + detail.className = 'detail'; + detail.textContent = item.detail; + + button.append(path, name, detail); + button.addEventListener('click', () => { + vscode.postMessage({ type: 'activate', index: item.index }); + }); + results.appendChild(button);`; + +export class DoomWhichKeyBindingsPanel extends DoomWebviewController { static readonly visibleContextKey = 'doom.whichKeyBindingsVisible'; - private activeIndex = 0; + protected readonly visibleContextKey = DoomWhichKeyBindingsPanel.visibleContextKey; + private bindings: WhichKeyExecutableBinding[] = []; private matches: WhichKeyBindingMatch[] = []; - private query = ''; - private ready = false; - private view: vscode.WebviewView | undefined; - private viewDisposables: vscode.Disposable[] = []; /** Call before making the panel visible. Resets query so prior search doesn't bleed into next open. */ prepareShow(resetQuery = true): void { @@ -64,56 +116,12 @@ export class DoomWhichKeyBindingsPanel { this.refreshItems(); } - /** Wires the panel to an already-created WebviewView (e.g. on sidebar restore). */ - attachToView(webviewView: vscode.WebviewView): void { - this.resolveWebviewView(webviewView); - } - - /** Tears down listeners and clears the view reference without destroying the panel instance. */ - detachFromView(): void { - this.viewDisposables.forEach((disposable) => disposable.dispose()); - this.viewDisposables = []; - this.view = undefined; - this.ready = false; - } - - /** - * Bootstraps a WebviewView: injects HTML, wires dispose/visibility/message listeners. - * Re-entrant — cleans up previous listeners before rebinding, so safe to call on view recycle. - */ - resolveWebviewView(webviewView: vscode.WebviewView): void { - this.viewDisposables.forEach((disposable) => disposable.dispose()); - this.viewDisposables = []; - this.view = webviewView; - webviewView.webview.options = { - enableScripts: true, - }; - webviewView.webview.html = this.getHtml(webviewView.webview); - this.updateViewMetadata(); - - this.viewDisposables.push( - webviewView.onDidDispose(() => { - if (this.view === webviewView) { - this.view = undefined; - this.ready = false; - } - }), - webviewView.onDidChangeVisibility(() => { - if (!webviewView.visible) { - return; - } - - this.refreshItems(); - this.render(); - }), - webviewView.webview.onDidReceiveMessage((message: WhichKeyBindingsMessage) => { - void this.handleMessage(message); - }) - ); + protected get itemCount(): number { + return this.matches.length; } /** Stamps static title/description onto the sidebar pane header. */ - private updateViewMetadata(): void { + protected updateViewMetadata(): void { if (!this.view) { return; } @@ -122,6 +130,16 @@ export class DoomWhichKeyBindingsPanel { this.view.description = 'Which-key command list'; } + /** Re-reads live config and re-renders each time the panel is revealed. */ + protected onVisibilityChanged(visible: boolean): void { + if (!visible) { + return; + } + + this.refreshItems(); + this.render(); + } + /** Re-reads live config and re-applies current filter — call when config may have changed. */ private refreshItems(): void { this.bindings = getFlattenedWhichKeyBindings(); @@ -133,7 +151,7 @@ export class DoomWhichKeyBindingsPanel { * Empty query shows all bindings unranked. Clamps `activeIndex` so it never goes out of bounds. * Match indices are computed against `path` only — highlight stays in the key column. */ - private filterItems(): void { + protected filterItems(): void { const query = this.query.trim().toLowerCase(); const matches = this.bindings .map((item, index) => { @@ -174,45 +192,8 @@ export class DoomWhichKeyBindingsPanel { : Math.min(this.activeIndex, this.matches.length - 1); } - /** Dispatches messages from the webview. `activate` optionally updates index before executing. */ - private async handleMessage(message: WhichKeyBindingsMessage): Promise { - switch (message.type) { - case 'ready': - this.ready = true; - this.render(); - return; - case 'query': - this.query = message.query ?? ''; - this.filterItems(); - this.render(); - return; - case 'move': { - if (this.matches.length === 0 || message.index === undefined) { - return; - } - - this.activeIndex = Math.min(Math.max(message.index, 0), this.matches.length - 1); - this.render(); - return; - } - case 'activate': { - if (message.index !== undefined) { - this.activeIndex = Math.min(Math.max(message.index, 0), this.matches.length - 1); - } - - await this.activateSelection(); - return; - } - case 'close': - await this.close(); - return; - default: - return; - } - } - /** Executes the active match's binding then closes the panel. No-op if list is empty. */ - private async activateSelection(): Promise { + protected async activateSelection(): Promise { const match = this.matches[this.activeIndex]; if (!match) { return; @@ -222,17 +203,8 @@ export class DoomWhichKeyBindingsPanel { await this.close(); } - /** Collapses the bottom panel — keeps the webview alive so state survives the next open. */ - private async close(): Promise { - await vscode.commands.executeCommand('workbench.action.closePanel'); - } - - /** Serializes current match/index state and pushes it to the webview via postMessage. Guards against rendering before 'ready'. */ - private render(): void { - if (!this.view || !this.ready || !this.view.visible) { - return; - } - + /** Serializes current match/index state into the render payload. Clamps `activeIndex` into range. */ + protected buildRenderState(): WhichKeyBindingsState { const state: WhichKeyBindingsState = { activeIndex: this.matches.length === 0 ? 0 @@ -254,326 +226,16 @@ export class DoomWhichKeyBindingsPanel { this.activeIndex = state.activeIndex; - void this.view.webview.postMessage({ - type: 'render', - state, - }); + return state; } - /** - * Generates the full webview HTML. Nonce-locked CSP prevents script injection. - * The embedded script owns all DOM interaction and communicates exclusively via postMessage. - */ - private getHtml(webview: vscode.Webview): string { - const nonce = createNonce(); - const csp = [ - "default-src 'none'", - `style-src ${webview.cspSource} 'unsafe-inline'`, - `script-src 'nonce-${nonce}'`, - ].join('; '); - - return ` - - - - - - Which-Key Bindings - - - -
-
-
0/0
-
Show bindings:
- -
-
- -
- - -`; } } \ No newline at end of file From 7d2cc2d988f87181b6cacb45ccd087efd31a7d9c Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 15:33:43 +0200 Subject: [PATCH 07/12] fix: dispose workspace file watchers via shared WorkspaceFileIndex DoomProjectFilePanel and DoomSearchPanel each lazily created a createFileSystemWatcher('**/*') stored in a field that was never pushed to context.subscriptions or disposed, leaking two full-tree watchers for the session. Extract a single WorkspaceFileIndex service that owns the one watcher, exposes getFiles() and an onCacheInvalidated event, and is registered in context.subscriptions so it is disposed with the extension. Both panels now subscribe to onCacheInvalidated to clear their own derived caches and no longer own a watcher. --- src/extension.ts | 7 ++-- src/search/projectFile.ts | 18 ++++------- src/search/search.ts | 16 ++++------ src/search/workspaceFileIndex.ts | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 src/search/workspaceFileIndex.ts diff --git a/src/extension.ts b/src/extension.ts index 098742d..b1706c9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,6 +27,7 @@ import { DoomSearchPanel } from './search/search'; import { DoomProjectFilePanel } from './search/projectFile'; import { DoomRecentProjectsPanel } from './search/recentProjects'; import { SelectionHistory } from './search/selectionHistory'; +import { WorkspaceFileIndex } from './search/workspaceFileIndex'; import { DoomWhichKeyBindingsPanel } from './whichkey/bindingsPanel'; import { DoomWhichKeyMenu } from './whichkey/menu'; import { showWhichKeyBindingsQuickPick } from './whichkey/showBindings'; @@ -909,7 +910,9 @@ export function activate(context: vscode.ExtensionContext) { DASHBOARD_OPEN_ON_ACTIVATION_SETTING, ...Object.keys(installDefaults), ]; - const searchPanel = new DoomSearchPanel(); + const workspaceFileIndex = new WorkspaceFileIndex(); + context.subscriptions.push(workspaceFileIndex); + const searchPanel = new DoomSearchPanel(workspaceFileIndex); /** Opens a project folder in the current window and suppresses the dashboard on the next activation. */ const openProjectAndSkipDashboard = async (projectUri: vscode.Uri): Promise => { @@ -946,7 +949,7 @@ export function activate(context: vscode.ExtensionContext) { ); const openEditorsPanel = new DoomOpenEditorsPanel(); const whichKeyBindingsPanel = new DoomWhichKeyBindingsPanel(); - const projectFilePanel = new DoomProjectFilePanel(selectionHistory); + const projectFilePanel = new DoomProjectFilePanel(selectionHistory, workspaceFileIndex); const recentProjectsPanel = new DoomRecentProjectsPanel(); const findFilePanel = new DoomFindFilePanel(selectionHistory); const sharedPanel = new DoomSharedPanel( diff --git a/src/search/projectFile.ts b/src/search/projectFile.ts index 2b43fd5..6605af7 100644 --- a/src/search/projectFile.ts +++ b/src/search/projectFile.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { DoomWebviewController } from '../panel/controller'; import { createFilePickerHtml, createNonce, formatFileSize, formatPermissions, formatRelativeTime, orderlessMatch } from '../panel/helpers'; import { SelectionHistory } from './selectionHistory'; +import { WorkspaceFileIndex } from './workspaceFileIndex'; const MAX_RESULTS = 200; @@ -88,8 +89,13 @@ export class DoomProjectFilePanel extends DoomWebviewController { protected readonly visibleContextKey = DoomProjectFilePanel.visibleContextKey; - constructor(private readonly history: SelectionHistory) { + constructor( + private readonly history: SelectionHistory, + private readonly fileIndex: WorkspaceFileIndex, + ) { super(); + // Drop the cached file list whenever the workspace tree changes. + this.fileIndex.onCacheInvalidated(() => { this.workspaceCache = undefined; }); } private allItems: ProjectFileItem[] = []; @@ -97,7 +103,6 @@ export class DoomProjectFilePanel extends DoomWebviewController { private loading = false; private loadSequence = 0; private workspaceCache: ProjectFileItem[] | undefined; - private workspaceCacheWatcher: vscode.FileSystemWatcher | undefined; /** Resets query/index and validates a workspace exists. Returns false if not. */ prepareShow(): boolean { @@ -235,15 +240,6 @@ export class DoomProjectFilePanel extends DoomWebviewController { } this.workspaceCache = items; - - if (!this.workspaceCacheWatcher) { - this.workspaceCacheWatcher = vscode.workspace.createFileSystemWatcher('**/*'); - const invalidate = (): void => { this.workspaceCache = undefined; }; - this.workspaceCacheWatcher.onDidCreate(invalidate); - this.workspaceCacheWatcher.onDidDelete(invalidate); - this.workspaceCacheWatcher.onDidChange(invalidate); - } - this.loading = false; this.seedItems(); this.render(); diff --git a/src/search/search.ts b/src/search/search.ts index 9b85cf9..a7282e1 100644 --- a/src/search/search.ts +++ b/src/search/search.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { DoomWebviewController } from '../panel/controller'; import { createNonce, createPanelHtml, substringMatch } from '../panel/helpers'; +import { WorkspaceFileIndex } from './workspaceFileIndex'; const MAX_RESULTS = 200; @@ -181,7 +182,12 @@ export class DoomSearchPanel extends DoomWebviewController { private startingSelection: vscode.Selection | undefined; private targetEditor: vscode.TextEditor | undefined; private workspaceCache: SearchItem[] | undefined; - private workspaceCacheWatcher: vscode.FileSystemWatcher | undefined; + + constructor(private readonly fileIndex: WorkspaceFileIndex) { + super(); + // Drop the cached workspace line index whenever the workspace tree changes. + this.fileIndex.onCacheInvalidated(() => { this.workspaceCache = undefined; }); + } /** Switches to editor mode and seeds search state from the active editor. Returns false if no editor is open. */ prepareShow(): boolean { @@ -418,14 +424,6 @@ export class DoomSearchPanel extends DoomWebviewController { } this.workspaceCache = items; - if (!this.workspaceCacheWatcher) { - this.workspaceCacheWatcher = vscode.workspace.createFileSystemWatcher('**/*'); - const invalidate = (): void => { this.workspaceCache = undefined; }; - this.workspaceCacheWatcher.onDidCreate(invalidate); - this.workspaceCacheWatcher.onDidDelete(invalidate); - this.workspaceCacheWatcher.onDidChange(invalidate); - } - this.loading = false; this.currentItems = items; this.filterItems(); diff --git a/src/search/workspaceFileIndex.ts b/src/search/workspaceFileIndex.ts new file mode 100644 index 0000000..088a6f9 --- /dev/null +++ b/src/search/workspaceFileIndex.ts @@ -0,0 +1,55 @@ +import * as vscode from 'vscode'; + +/** + * Single, extension-scoped source of workspace-file discovery and change + * notification. + * + * Owns the one and only `vscode.workspace.createFileSystemWatcher('**\/*')` for + * the extension. The find-file and search panels each used to lazily create — + * and never dispose — their own full-tree watcher to invalidate their cached + * file lists; that leaked two workspace-wide watchers for the whole session. + * They now share this service: it watches the tree once and fires + * {@link onCacheInvalidated} on any create/delete/change so each panel can clear + * its own derived cache. + * + * Created and pushed to `context.subscriptions` in `activate()`, so the watcher + * and event emitter are disposed when the extension deactivates. + */ +export class WorkspaceFileIndex implements vscode.Disposable { + private readonly watcher: vscode.FileSystemWatcher; + private readonly cacheInvalidated = new vscode.EventEmitter(); + private readonly disposables: vscode.Disposable[] = []; + private fileCache: vscode.Uri[] | undefined; + + /** Fires whenever a workspace file is created, deleted, or changed. */ + readonly onCacheInvalidated = this.cacheInvalidated.event; + + constructor() { + this.watcher = vscode.workspace.createFileSystemWatcher('**/*'); + const invalidate = (): void => { + this.fileCache = undefined; + this.cacheInvalidated.fire(); + }; + this.disposables.push( + this.watcher, + this.cacheInvalidated, + this.watcher.onDidCreate(invalidate), + this.watcher.onDidDelete(invalidate), + this.watcher.onDidChange(invalidate), + ); + } + + /** Returns all workspace files, cached until the next filesystem change. */ + async getFiles(): Promise { + if (!this.fileCache) { + this.fileCache = await vscode.workspace.findFiles('**/*'); + } + + return this.fileCache; + } + + dispose(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables.length = 0; + } +} From 627a270fa7a83e6e225ffa21e2606601653190fa Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 16:17:37 +0200 Subject: [PATCH 08/12] refactor: split extension entry point into focused command modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose src/extension.ts (~1.5k lines mixing onboarding I/O, terminal management, window commands, and ~20 inline command bodies) into cohesive modules, each exposing a register()/named API: - onboarding/keybindingsFile.ts — keybindings.json read/parse/rewrite - onboarding/staleCleanup.ts — stale-settings cleanup + legacy binding migration - onboarding/onboardingCommands.ts — doom.install, doom.cleanup, doom.dashboard - terminal/terminalCommands.ts — editor/AI-CLI and panel terminal commands - window/windowCommands.ts — doom.windowDelete and window focus/split commands activate() is now a short wiring list that calls each module's register(). Also: - Collapse the three copy-paste AI-CLI commands into a single table-driven openCliTerminal() helper that derives EDITOR_TERMINAL_NAMES. - Move the pure resolveWindowDeleteAction into window/windowCommands.ts, removing the command modules' import dependency on extension.ts; update the test import to match. --- src/extension.ts | 754 +-------------------------- src/onboarding/keybindingsFile.ts | 184 +++++++ src/onboarding/onboardingCommands.ts | 257 +++++++++ src/onboarding/staleCleanup.ts | 103 ++++ src/terminal/terminalCommands.ts | 102 ++++ src/test/extension.test.ts | 2 +- src/window/windowCommands.ts | 108 ++++ 7 files changed, 778 insertions(+), 732 deletions(-) create mode 100644 src/onboarding/keybindingsFile.ts create mode 100644 src/onboarding/onboardingCommands.ts create mode 100644 src/onboarding/staleCleanup.ts create mode 100644 src/terminal/terminalCommands.ts create mode 100644 src/window/windowCommands.ts diff --git a/src/extension.ts b/src/extension.ts index b1706c9..4d4ebb7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,5 @@ // The module 'vscode' contains the VS Code extensibility API // Import the module and reference it with the alias vscode in your code below -import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; @@ -13,13 +12,19 @@ import { resolveStartupCommandsFromBindings, } from './onboarding/dashboard'; import { - ApplyDefaultsOptions, - ApplyDefaultsResult, - applyDefaultsToConfiguration, - runInstallFlow, - type VimBindingConflict, - type VimBindingConflictDecision, -} from './onboarding/install'; + getDoomUserKeybindings, + getKeybindingsPath, + readKeybindingsJson, +} from './onboarding/keybindingsFile'; +import { + CONFLICTING_EXTENSIONS, + detectConflictingExtensions, + register as registerOnboardingCommands, +} from './onboarding/onboardingCommands'; +import { + containsStaleCommand, + STALE_COMMAND_PREFIXES, +} from './onboarding/staleCleanup'; import { DOOM_STALE_VIM_BINDING_SETTINGS } from './onboarding/vimBindings'; import { DoomSharedPanel } from './panel/shared'; import { DoomFindFilePanel } from './search/findFile'; @@ -28,10 +33,12 @@ import { DoomProjectFilePanel } from './search/projectFile'; import { DoomRecentProjectsPanel } from './search/recentProjects'; import { SelectionHistory } from './search/selectionHistory'; import { WorkspaceFileIndex } from './search/workspaceFileIndex'; +import * as terminalCommands from './terminal/terminalCommands'; import { DoomWhichKeyBindingsPanel } from './whichkey/bindingsPanel'; import { DoomWhichKeyMenu } from './whichkey/menu'; import { showWhichKeyBindingsQuickPick } from './whichkey/showBindings'; -import { focusEditorGroup, focusWindowDown, focusWindowLeft, focusWindowRight, focusWindowUp, registerWindowMru } from './window/mru'; +import { registerWindowMru } from './window/mru'; +import * as windowCommands from './window/windowCommands'; type WhichKeyMenuStyle = 'doom' | 'vspacecode'; @@ -47,10 +54,6 @@ const SKIP_DASHBOARD_KEY = 'doom.skipDashboardOnActivation'; const TERMINAL_ESCAPE_TIMEOUT_MS = 2000; const DASHBOARD_REFRESH_DEBOUNCE_MS = 50; -const KEEP_EXISTING_BINDING_ACTION = 'Keep Existing'; -const OVERWRITE_WITH_DOOM_ACTION = 'Overwrite with Doom'; -const KEEP_ALL_EXISTING_BINDINGS_ACTION = 'Keep All Existing'; -const OVERWRITE_ALL_WITH_DOOM_ACTION = 'Overwrite All with Doom'; export interface StoredWorkspaceTarget { label: string; @@ -155,27 +158,6 @@ export function selectReloadWorkspaceTarget( return undefined; } -export type WindowDeleteAction = 'closeGroup' | 'closePanel' | 'moveTerminalEditorToPanelAndCloseGroup'; - -/** - * Pure function: determines the correct `doom.windowDelete` action based on focus context. - * Terminal panel focus → close panel. Terminal editor tab → move back to panel first. Otherwise → close group. - */ -export function resolveWindowDeleteAction( - terminalFocus: boolean, - activeTerminalEditor: boolean, -): WindowDeleteAction { - if (terminalFocus && !activeTerminalEditor) { - return 'closePanel'; - } - - if (activeTerminalEditor) { - return 'moveTerminalEditorToPanelAndCloseGroup'; - } - - return 'closeGroup'; -} - /** Snapshots the current workspace into globalState if it differs from the last recorded one. */ async function persistWorkspaceHistory(context: vscode.ExtensionContext): Promise { const next = computeWorkspaceHistoryUpdate( @@ -268,20 +250,6 @@ async function showConfiguredWhichKeyMenu( await sharedPanel.showWhichKey(); } -// --------------------------------------------------------------------------- -// Conflicting extensions that override the same settings Doom Code manages. -// --------------------------------------------------------------------------- -const CONFLICTING_EXTENSIONS = [ - { - id: "VSpaceCode.vspacecode", - name: "VSpaceCode", - reason: "overrides whichkey.bindings and vim keybindings with its own defaults", - }, -]; - -// Stale command prefixes left behind by conflicting extensions. -const STALE_COMMAND_PREFIXES = ["vspacecode."]; - // --------------------------------------------------------------------------- // Install defaults // --------------------------------------------------------------------------- @@ -333,357 +301,6 @@ function getStartupCommandKeyPaths(context: vscode.ExtensionContext): string[][] }); } -/** - * Writes install defaults to the user's global settings, skipping user-owned keys. - * Optionally shows a toast summarising applied/skipped/failed counts. - */ -async function applyDefaultsToUserSettings( - defaults: Record, - showResultMessage = false, - options: ApplyDefaultsOptions = {}, -): Promise { - const config = vscode.workspace.getConfiguration(); - const result = await applyDefaultsToConfiguration(config, defaults, vscode.ConfigurationTarget.Global, options); - - if (showResultMessage) { - if (result.total === 0) { - void vscode.window.showWarningMessage("No Doom install defaults are configured in package.json."); - return result; - } - - const parts: string[] = [ - `${result.applied} applied`, - `${result.skipped} skipped (already customized by you)`, - ]; - if (result.unsupported > 0) { - parts.push(`${result.unsupported} not recognized by VS Code`); - } - if (result.failed > 0) { - parts.push(`${result.failed} failed`); - } - const failureDetails = result.failures.length > 0 - ? ` Failures: ${result.failures.slice(0, 3).map((failure) => ( - `${failure.key} (${failure.reason})` - )).join('; ')}${result.failures.length > 3 ? `; +${result.failures.length - 3} more` : ''}.` - : ''; - void vscode.window.showInformationMessage( - `Doom defaults applied to your global User settings: ${parts.join(', ')}.${failureDetails}` - ); - } - - return result; -} - -function summarizeValueForUi(value: unknown, maxLength = 180): string { - let serialized: string; - try { - serialized = JSON.stringify(value); - } catch { - serialized = String(value); - } - - return serialized.length <= maxLength ? serialized : `${serialized.slice(0, maxLength - 1)}…`; -} - -function formatVimBindingChord(before: readonly string[]): string { - return before.join(' '); -} - -function createVimBindingConflictResolver(): ApplyDefaultsOptions['resolveVimBindingConflict'] { - let rememberedDecision: VimBindingConflictDecision | undefined; - - return async (conflict: VimBindingConflict) => { - if (rememberedDecision) { - return rememberedDecision; - } - - const choice = await vscode.window.showWarningMessage( - `Doom Code: ${conflict.settingKey} already contains a binding for ${formatVimBindingChord(conflict.before)}.`, - { - modal: true, - detail: [ - `Existing: ${conflict.existingEntries.map((entry) => summarizeValueForUi(entry)).join(' | ')}`, - `Doom: ${summarizeValueForUi(conflict.defaultEntry)}`, - 'Keep existing preserves your current mapping. Overwrite replaces all conflicting bindings for this chord with Doom\'s default.', - ].join('\n'), - }, - KEEP_EXISTING_BINDING_ACTION, - OVERWRITE_WITH_DOOM_ACTION, - KEEP_ALL_EXISTING_BINDINGS_ACTION, - OVERWRITE_ALL_WITH_DOOM_ACTION, - ); - - if (choice === OVERWRITE_ALL_WITH_DOOM_ACTION) { - rememberedDecision = 'overwrite'; - return 'overwrite'; - } - - if (choice === KEEP_ALL_EXISTING_BINDINGS_ACTION) { - rememberedDecision = 'keep'; - return 'keep'; - } - - if (choice === OVERWRITE_WITH_DOOM_ACTION) { - return 'overwrite'; - } - - return 'keep'; - }; -} - -// --------------------------------------------------------------------------- -// Conflict detection -// --------------------------------------------------------------------------- - -/** Returns the subset of `CONFLICTING_EXTENSIONS` that are currently installed. */ -function detectConflictingExtensions(): typeof CONFLICTING_EXTENSIONS { - return CONFLICTING_EXTENSIONS.filter( - (ext) => vscode.extensions.getExtension(ext.id) !== undefined - ); -} - -/** Shows a modal warning for each conflicting extension with an "Open Extensions" action. */ -async function warnAboutConflicts(conflicts: typeof CONFLICTING_EXTENSIONS): Promise { - for (const ext of conflicts) { - const choice = await vscode.window.showWarningMessage( - `Doom Code: "${ext.name}" is installed and ${ext.reason}. This will cause keybinding conflicts. Please uninstall "${ext.name}" and reload.`, - "Open Extensions" - ); - if (choice === "Open Extensions") { - await vscode.commands.executeCommand("workbench.extensions.action.showInstalledExtensions"); - } - } -} - -// --------------------------------------------------------------------------- -// Stale-command cleanup (settings.json + keybindings.json) -// --------------------------------------------------------------------------- - -/** - * Returns true if `value` (at any depth) contains a string that starts with - * one of the stale command prefixes. - */ -function containsStaleCommand(value: unknown): boolean { - if (typeof value === 'string') { - return STALE_COMMAND_PREFIXES.some((p) => value.startsWith(p)); - } - if (Array.isArray(value)) { - return value.some(containsStaleCommand); - } - if (value !== null && typeof value === 'object') { - return Object.values(value as Record).some(containsStaleCommand); - } - return false; -} - -/** - * Scan vim keybinding arrays in user settings for stale commands. - * Only removes individual stale entries, preserving all user-defined bindings. - * Returns the list of setting keys that were cleaned. - */ -async function cleanStaleSettings(): Promise { - const config = vscode.workspace.getConfiguration(); - const keysToCheck = DOOM_STALE_VIM_BINDING_SETTINGS; - - const cleaned: string[] = []; - - for (const key of keysToCheck) { - const inspected = config.inspect(key); - const currentValue = inspected?.globalValue; - if (!Array.isArray(currentValue)) { - continue; - } - - const filtered = currentValue.filter((entry) => !containsStaleCommand(entry)); - if (filtered.length !== currentValue.length) { - await config.update(key, filtered, vscode.ConfigurationTarget.Global); - cleaned.push(key); - } - } - - return cleaned; -} - -/** - * Read the user keybindings.json, filter out entries whose `command` starts - * with a stale prefix, and write back if anything changed. - * Returns the number of entries removed. - */ -async function cleanStaleKeybindings(context: vscode.ExtensionContext): Promise { - const keybindingsPath = getKeybindingsPath(context); - if (!keybindingsPath) { - return 0; - } - - const bindings = readKeybindingsJson(keybindingsPath); - if (!bindings) { - return 0; - } - - const before = bindings.length; - const filtered = bindings.filter((entry) => { - const cmd = entry.command; - if (typeof cmd !== 'string') { return true; } - // Keep negations (e.g. "-vspacecode.space") — they disable a default. - if (cmd.startsWith('-')) { return true; } - return !STALE_COMMAND_PREFIXES.some((p) => cmd.startsWith(p)); - }); - - const removed = before - filtered.length; - if (removed === 0) { return 0; } - - const output = "// Place your key bindings in this file to override the defaults\n" - + JSON.stringify(filtered, null, '\t') - + '\n'; - - try { - fs.writeFileSync(keybindingsPath, output, 'utf-8'); - } catch (err) { - console.warn("Doom Code: failed to write cleaned keybindings.json:", err); - return 0; - } - - return removed; -} - -function getKeybindingsPath(context: vscode.ExtensionContext): string | undefined { - // globalStorageUri points to: - // /User/globalStorage/ (default profile) - // /User/profiles//globalStorage/ (named profile) - // Go up 2 levels to reach the active profile's User directory. - const profileDir = path.dirname(path.dirname(context.globalStorageUri.fsPath)); - return path.join(profileDir, 'keybindings.json'); -} - -/** - * Reads and parses a VS Code keybindings.json, tolerating single-line comments - * and trailing commas. Returns the parsed array, or undefined if the file is - * missing, unreadable, or malformed. - */ -function readKeybindingsJson(keybindingsPath: string): Array> | undefined { - if (!fs.existsSync(keybindingsPath)) { - return undefined; - } - try { - const raw = fs.readFileSync(keybindingsPath, 'utf-8'); - const stripped = raw.replace(/^\s*\/\/.*$/gm, ''); - const sanitized = stripped.replace(/,\s*([}\]])/g, '$1'); - const parsed = JSON.parse(sanitized); - return Array.isArray(parsed) ? parsed : undefined; - } catch (err) { - console.warn('[Doom] readKeybindingsJson failed:', err); - return undefined; - } -} - -/** - * Returns the magit-related keybindings from Doom's own contributes.keybindings: - * entries scoped to the magit editor language and negation entries that disable - * kahole.magit's default key assignments. - */ -function getDoomUserKeybindings(context: vscode.ExtensionContext): Array> { - const doomKeybindings = (context.extension.packageJSON as { - contributes?: { keybindings?: Array> }; - }).contributes?.keybindings; - - if (!Array.isArray(doomKeybindings)) { - return []; - } - - const magitKeybindings = doomKeybindings.filter((kb) => { - const when = kb['when']; - return typeof when === 'string' && when.includes("editorLangId == 'magit'"); - }); - - // Negation entries that disable kahole.magit's default key assignments. - const negationKeybindings = doomKeybindings.filter((kb) => { - const cmd = kb['command']; - return typeof cmd === 'string' && cmd.startsWith('-magit.'); - }); - - return [...magitKeybindings, ...negationKeybindings]; -} - -/** - * Read the user keybindings.json, add any magit-related keybindings declared - * in Doom's own contributes.keybindings that are not already present, and - * write back. User-level keybindings have higher precedence than all - * extension keybindings, which is necessary for magit.dispatch to display the - * correct key hints. - * Returns the number of keybindings added. - */ -async function installDoomKeybindings(context: vscode.ExtensionContext): Promise { - const allMagitRelated = getDoomUserKeybindings(context); - - if (allMagitRelated.length === 0) { - return 0; - } - - const keybindingsPath = getKeybindingsPath(context); - if (!keybindingsPath) { - return 0; - } - - let existing: Array> = []; - let rawContent: string | undefined; - if (fs.existsSync(keybindingsPath)) { - try { - rawContent = fs.readFileSync(keybindingsPath, 'utf-8'); - } catch { - console.warn("Doom Code: could not read keybindings.json, skipping magit install."); - return 0; - } - const parsed = readKeybindingsJson(keybindingsPath); - if (parsed === undefined) { - console.warn("Doom Code: could not parse keybindings.json, skipping magit install."); - return 0; - } - existing = parsed; - } - - const toAdd = allMagitRelated.filter((kb) => - !existing.some((e) => e['key'] === kb['key'] && e['command'] === kb['command'] && e['when'] === kb['when']), - ); - - if (toAdd.length === 0) { - return 0; - } - - let output: string; - const newEntries = toAdd.map((kb) => '\t' + JSON.stringify(kb)).join(',\n'); - const block = '\t// #region Doom Code keybindings\n' + newEntries + '\n\t// #endregion Doom Code keybindings'; - - if (rawContent !== undefined && existing.length > 0) { - // Append to existing file — preserve original content and comments. - const lastBracket = rawContent.lastIndexOf(']'); - if (lastBracket !== -1) { - const beforeBracket = rawContent.slice(0, lastBracket).trimEnd(); - const rest = rawContent.slice(lastBracket + 1); - output = beforeBracket + ',\n' + block + '\n]' + rest; - } else { - // Malformed — fall back to full rewrite. - output = "// Place your key bindings in this file to override the defaults\n" - + JSON.stringify([...existing, ...toAdd], null, '\t') - + '\n'; - } - } else { - // File doesn't exist or is empty — write fresh. - output = "// Place your key bindings in this file to override the defaults\n[\n" - + block - + '\n]\n'; - } - - try { - fs.mkdirSync(path.dirname(keybindingsPath), { recursive: true }); - fs.writeFileSync(keybindingsPath, output, 'utf-8'); - } catch (err) { - console.warn("Doom Code: failed to write magit keybindings to keybindings.json:", err); - return 0; - } - - return toAdd.length; -} - // --------------------------------------------------------------------------- // Detection-only check (reads state, never mutates) // --------------------------------------------------------------------------- @@ -728,42 +345,6 @@ function detectStaleState(context: vscode.ExtensionContext): StaleDetectionResul return { conflicts, hasStaleSettings, hasStaleKeybindings, hasMagitKeybindings }; } -// --------------------------------------------------------------------------- -// Full cleanup — mutating path (manual command or user-confirmed) -// --------------------------------------------------------------------------- - -/** Runs both setting and keybinding cleanup, then shows a summary toast with a "Reload" action. */ -async function runCleanup(context: vscode.ExtensionContext): Promise { - const cleanedSettings = await cleanStaleSettings(); - const removedKeybindings = await cleanStaleKeybindings(context); - - const conflicts = detectConflictingExtensions(); - - const parts: string[] = []; - if (cleanedSettings.length > 0) { - parts.push(`cleaned ${cleanedSettings.length} setting(s)`); - } - if (removedKeybindings > 0) { - parts.push(`removed ${removedKeybindings} stale keybinding(s)`); - } - if (conflicts.length > 0) { - parts.push(`${conflicts.length} conflicting extension(s) detected`); - } - - if (parts.length > 0) { - void vscode.window.showInformationMessage( - `Doom Code cleanup: ${parts.join(', ')}. Please reload the window.`, - "Reload" - ).then((choice: string | undefined) => { - if (choice === "Reload") { - void vscode.commands.executeCommand("workbench.action.reloadWindow"); - } - }); - } else { - void vscode.window.showInformationMessage("Doom Code: no stale settings or conflicts found."); - } -} - /** Extracts version and repository URL from package.json for display in the start page. */ function getExtensionMetadata(context: vscode.ExtensionContext): { version: string; @@ -838,63 +419,6 @@ function refreshDashboardIfOpen( dashboard.refresh(createDashboardState(context, mode, installDefaults)); } -// --------------------------------------------------------------------------- -// Which-key migration -// --------------------------------------------------------------------------- - -/** One-time migration: rewrites `whichkey.show` → `doom.whichKeyShow` in user vim keybindings so SPC still works after install. */ -async function migrateLegacyWhichKeyShowBindings(): Promise { - const config = vscode.workspace.getConfiguration(); - const keysToCheck = [ - "vim.normalModeKeyBindingsNonRecursive", - "vim.visualModeKeyBindingsNonRecursive", - ]; - - for (const key of keysToCheck) { - const inspected = config.inspect(key); - const currentValue = inspected?.globalValue; - if (!Array.isArray(currentValue)) { - continue; - } - - let changed = false; - const migrated = currentValue.map((entry) => { - if ( - entry !== null - && typeof entry === 'object' - && 'before' in entry - && 'commands' in entry - ) { - const binding = entry as { - before?: unknown; - commands?: unknown; - }; - - if ( - Array.isArray(binding.before) - && binding.before.length === 1 - && binding.before[0] === '' - && Array.isArray(binding.commands) - && binding.commands.length === 1 - && binding.commands[0] === 'whichkey.show' - ) { - changed = true; - return { - ...entry, - commands: ['doom.whichKeyShow'], - }; - } - } - - return entry; - }); - - if (changed) { - await config.update(key, migrated, vscode.ConfigurationTarget.Global); - } - } -} - // --------------------------------------------------------------------------- // Extension lifecycle // --------------------------------------------------------------------------- @@ -962,69 +486,11 @@ export function activate(context: vscode.ExtensionContext) { findFilePanel, ); - // Manual install command - const installCmd = vscode.commands.registerCommand( - "doom.install", - async () => { - const result = await runInstallFlow( - async () => { - const choice = await vscode.window.showWarningMessage( - "Apply Doom default settings and keybindings to your User settings? Existing user-owned values stay untouched unless you choose to overwrite a conflicting Doom Vim binding.", - { modal: true }, - "Apply" - ); - return choice === "Apply"; - }, - async () => { - await migrateLegacyWhichKeyShowBindings(); - const settingsResult = await applyDefaultsToUserSettings(installDefaults, true, { - resolveVimBindingConflict: createVimBindingConflictResolver(), - }); - const addedKeybindings = await installDoomKeybindings(context); - if (addedKeybindings > 0) { - void vscode.window.showInformationMessage( - `Doom Code: installed ${addedKeybindings} magit keybinding(s) into keybindings.json so the magit dispatch recognises them.` - ); - } - return settingsResult; - }, - ); - - if (!result) { - return; - } - - scheduleDashboardRefresh(0); - } - ); - - // Manual cleanup command - const cleanupCmd = vscode.commands.registerCommand( - "doom.cleanup", - async () => { - const choice = await vscode.window.showWarningMessage( - "This will remove stale settings and keybindings left behind by conflicting extensions (e.g. vspacecode.* commands) from your User settings.json and keybindings.json. Note: keybindings.json will be rewritten — all comments and custom formatting will be lost. This cannot be undone.", - { modal: true }, - "Clean Up" - ); - if (choice !== "Clean Up") { - return; - } - const conflicts = detectConflictingExtensions(); - if (conflicts.length > 0) { - await warnAboutConflicts(conflicts); - } - await runCleanup(context); - scheduleDashboardRefresh(0); - } - ); - - const showDashboardCmd = vscode.commands.registerCommand( - "doom.dashboard", - async () => { - await showDashboard(context, dashboard, 'startup', installDefaults); - } - ); + registerOnboardingCommands(context, { + installDefaults, + scheduleDashboardRefresh, + showStartupDashboard: () => showDashboard(context, dashboard, 'startup', installDefaults), + }); const reloadLastSessionCmd = vscode.commands.registerCommand( "doom.reloadLastSession", @@ -1133,169 +599,8 @@ export function activate(context: vscode.ExtensionContext) { } ); - const VTERM_NAME = '*vterm*'; - const VTERM_PREFIX = '*vterm*'; - const EDITOR_TERMINAL_NAMES = new Set(['codex', 'claude', 'claude code', 'copilot']); - - const isVtermName = (name: string) => - name === VTERM_NAME - || name.startsWith(`${VTERM_PREFIX}<`) - || EDITOR_TERMINAL_NAMES.has(name.toLowerCase()); - - const managedVtermSet = new Set(); - - /** Creates a named editor-group terminal so `doom.openPanelTerminal` can exclude it by name. */ - const createTerminalEditorCmd = vscode.commands.registerCommand( - "doom.createTerminalEditor", - async () => { - const vtermCount = vscode.window.terminals.filter((t) => isVtermName(t.name)).length; - const name = vtermCount === 0 ? VTERM_NAME : `${VTERM_PREFIX}<${vtermCount + 1}>`; - const terminal = vscode.window.createTerminal({ - name, - location: vscode.TerminalLocation.Editor, - }); - managedVtermSet.add(terminal); - terminal.show(); - // Lock the title so the shell (bash PROMPT_COMMAND / PS1) cannot override it - await vscode.commands.executeCommand('workbench.action.terminal.renameWithArg', { name }); - } - ); - - /** - * Opens AI tool CLIs in editor terminals with fixed names. - * Each terminal gets a consistent name ('claude', 'copilot', 'codex') so that: - * - They're recognized by isVtermName() and excluded from panel terminal switching (SPC o t) - * - Users can reliably find CLI terminals by name - * Creates a new terminal each trigger (no reuse). - */ - - const openClaudeCliCmd = vscode.commands.registerCommand( - "doom.openClaudeCli", - async () => { - const terminal = vscode.window.createTerminal({ - name: 'claude', - location: vscode.TerminalLocation.Editor, - }); - terminal.show(); - await vscode.commands.executeCommand('workbench.action.terminal.renameWithArg', { name: 'claude' }); - terminal.sendText('claude'); - } - ); - - const openCopilotCliCmd = vscode.commands.registerCommand( - "doom.openCopilotCli", - async () => { - const terminal = vscode.window.createTerminal({ - name: 'copilot', - location: vscode.TerminalLocation.Editor, - }); - terminal.show(); - await vscode.commands.executeCommand('workbench.action.terminal.renameWithArg', { name: 'copilot' }); - terminal.sendText('copilot'); - } - ); - - const openCodexCliCmd = vscode.commands.registerCommand( - "doom.openCodexCli", - async () => { - const terminal = vscode.window.createTerminal({ - name: 'codex', - location: vscode.TerminalLocation.Editor, - }); - terminal.show(); - await vscode.commands.executeCommand('workbench.action.terminal.renameWithArg', { name: 'codex' }); - terminal.sendText('codex'); - } - ); - - /** - * Opens the panel terminal without disturbing terminals in editor groups. - * Editor terminals created via `doom.createTerminalEditor` are named `*vterm*` or `*vterm*`. - * Known CLI editor terminals such as `codex` and `claude code` are also excluded by name. - * Panel terminals are anything not carrying those names. - * Falls back to creating a new panel terminal only when none exist. - * Uses show(true) to pre-select the terminal, then workbench.view.terminal to reliably - * open the panel — terminal.show() alone doesn't guarantee the panel opens. - */ - const openPanelTerminalCmd = vscode.commands.registerCommand( - "doom.openPanelTerminal", - () => { - const panelTerminals = vscode.window.terminals.filter((t) => !isVtermName(t.name)); - - if (panelTerminals.length > 0) { - panelTerminals[panelTerminals.length - 1].show(false); - } else { - vscode.window.createTerminal({ location: vscode.TerminalLocation.Panel }).show(false); - } - } - ); - - const windowDeleteCmd = vscode.commands.registerCommand( - "doom.windowDelete", - async () => { - const activeGroup = vscode.window.tabGroups.activeTabGroup; - const activeTerminalEditor = activeGroup.activeTab?.input instanceof vscode.TabInputTerminal; - const action = resolveWindowDeleteAction( - whichKeyMenu.showContext.terminalFocus, - activeTerminalEditor, - ); - - if (action === 'closePanel') { - await vscode.commands.executeCommand('workbench.action.closePanel'); - return; - } - - if (action === 'moveTerminalEditorToPanelAndCloseGroup') { - await vscode.commands.executeCommand('workbench.action.terminal.moveToTerminalPanel'); - await focusEditorGroup(activeGroup.viewColumn); - await vscode.commands.executeCommand('workbench.action.closeGroup'); - return; - } - - // Use the group that was active when whichkey opened (preWhichKeyEditorGroupColumn is set - // during whichkey command execution and undefined for direct invocations). This avoids - // relying on workbench.action.closeGroup honouring focus, which VS Code does not guarantee - // after the whichkey panel closes. - const targetColumn = whichKeyMenu.preWhichKeyEditorGroupColumn ?? activeGroup.viewColumn; - const groupToClose = vscode.window.tabGroups.all.find(g => g.viewColumn === targetColumn) - ?? activeGroup; - await vscode.window.tabGroups.close(groupToClose); - } - ); - - const windowLeftCmd = vscode.commands.registerCommand( - "doom.windowLeft", - async () => { - const activeGroup = vscode.window.tabGroups.activeTabGroup; - const explorerVisible = whichKeyMenu.trackedUiContext.explorerViewletVisible; - await focusWindowLeft(activeGroup, vscode.window.tabGroups.all, explorerVisible, whichKeyMenu.showContext.explorerFocused); - } - ); - - const windowRightCmd = vscode.commands.registerCommand( - "doom.windowRight", - async () => { - const activeGroup = vscode.window.tabGroups.activeTabGroup; - await focusWindowRight(whichKeyMenu.showContext.explorerFocused, activeGroup, vscode.window.tabGroups.all); - } - ); - - const windowUpCmd = vscode.commands.registerCommand( - "doom.windowUp", - async () => { - const panelFocused = whichKeyMenu.showContext.terminalFocus && whichKeyMenu.showContext.terminalPanelOpen; - await focusWindowUp(panelFocused); - } - ); - - const windowDownCmd = vscode.commands.registerCommand( - "doom.windowDown", - async () => { - const activeGroup = vscode.window.tabGroups.activeTabGroup; - const panelVisible = whichKeyMenu.trackedUiContext.activePanel !== ''; - await focusWindowDown(activeGroup, panelVisible); - } - ); + terminalCommands.register(context); + windowCommands.register(context, { whichKeyMenu }); const configurationChangeListener = vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration(WHICH_KEY_MENU_SETTING) && getWhichKeyMenuStyle() === 'vspacecode') { @@ -1426,9 +731,6 @@ export function activate(context: vscode.ExtensionContext) { })(); context.subscriptions.push( - installCmd, - cleanupCmd, - showDashboardCmd, reloadLastSessionCmd, whichKeyCmd, whichKeyBindingsCmd, @@ -1439,16 +741,6 @@ export function activate(context: vscode.ExtensionContext) { terminalSendEscapeCmd, sidebarHideCmd, panelHideCmd, - createTerminalEditorCmd, - openClaudeCliCmd, - openCopilotCliCmd, - openCodexCliCmd, - openPanelTerminalCmd, - windowDeleteCmd, - windowLeftCmd, - windowRightCmd, - windowUpCmd, - windowDownCmd, configurationChangeListener, fuzzySearchCmd, workspaceFuzzySearchCmd, diff --git a/src/onboarding/keybindingsFile.ts b/src/onboarding/keybindingsFile.ts new file mode 100644 index 0000000..bd68411 --- /dev/null +++ b/src/onboarding/keybindingsFile.ts @@ -0,0 +1,184 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { STALE_COMMAND_PREFIXES } from './staleCleanup'; + +export function getKeybindingsPath(context: vscode.ExtensionContext): string | undefined { + // globalStorageUri points to: + // /User/globalStorage/ (default profile) + // /User/profiles//globalStorage/ (named profile) + // Go up 2 levels to reach the active profile's User directory. + const profileDir = path.dirname(path.dirname(context.globalStorageUri.fsPath)); + return path.join(profileDir, 'keybindings.json'); +} + +/** + * Reads and parses a VS Code keybindings.json, tolerating single-line comments + * and trailing commas. Returns the parsed array, or undefined if the file is + * missing, unreadable, or malformed. + */ +export function readKeybindingsJson(keybindingsPath: string): Array> | undefined { + if (!fs.existsSync(keybindingsPath)) { + return undefined; + } + try { + const raw = fs.readFileSync(keybindingsPath, 'utf-8'); + const stripped = raw.replace(/^\s*\/\/.*$/gm, ''); + const sanitized = stripped.replace(/,\s*([}\]])/g, '$1'); + const parsed = JSON.parse(sanitized); + return Array.isArray(parsed) ? parsed : undefined; + } catch (err) { + console.warn('[Doom] readKeybindingsJson failed:', err); + return undefined; + } +} + +/** + * Read the user keybindings.json, filter out entries whose `command` starts + * with a stale prefix, and write back if anything changed. + * Returns the number of entries removed. + */ +export async function cleanStaleKeybindings(context: vscode.ExtensionContext): Promise { + const keybindingsPath = getKeybindingsPath(context); + if (!keybindingsPath) { + return 0; + } + + const bindings = readKeybindingsJson(keybindingsPath); + if (!bindings) { + return 0; + } + + const before = bindings.length; + const filtered = bindings.filter((entry) => { + const cmd = entry.command; + if (typeof cmd !== 'string') { return true; } + // Keep negations (e.g. "-vspacecode.space") — they disable a default. + if (cmd.startsWith('-')) { return true; } + return !STALE_COMMAND_PREFIXES.some((p) => cmd.startsWith(p)); + }); + + const removed = before - filtered.length; + if (removed === 0) { return 0; } + + const output = "// Place your key bindings in this file to override the defaults\n" + + JSON.stringify(filtered, null, '\t') + + '\n'; + + try { + fs.writeFileSync(keybindingsPath, output, 'utf-8'); + } catch (err) { + console.warn("Doom Code: failed to write cleaned keybindings.json:", err); + return 0; + } + + return removed; +} + +/** + * Returns the magit-related keybindings from Doom's own contributes.keybindings: + * entries scoped to the magit editor language and negation entries that disable + * kahole.magit's default key assignments. + */ +export function getDoomUserKeybindings(context: vscode.ExtensionContext): Array> { + const doomKeybindings = (context.extension.packageJSON as { + contributes?: { keybindings?: Array> }; + }).contributes?.keybindings; + + if (!Array.isArray(doomKeybindings)) { + return []; + } + + const magitKeybindings = doomKeybindings.filter((kb) => { + const when = kb['when']; + return typeof when === 'string' && when.includes("editorLangId == 'magit'"); + }); + + // Negation entries that disable kahole.magit's default key assignments. + const negationKeybindings = doomKeybindings.filter((kb) => { + const cmd = kb['command']; + return typeof cmd === 'string' && cmd.startsWith('-magit.'); + }); + + return [...magitKeybindings, ...negationKeybindings]; +} + +/** + * Read the user keybindings.json, add any magit-related keybindings declared + * in Doom's own contributes.keybindings that are not already present, and + * write back. User-level keybindings have higher precedence than all + * extension keybindings, which is necessary for magit.dispatch to display the + * correct key hints. + * Returns the number of keybindings added. + */ +export async function installDoomKeybindings(context: vscode.ExtensionContext): Promise { + const allMagitRelated = getDoomUserKeybindings(context); + + if (allMagitRelated.length === 0) { + return 0; + } + + const keybindingsPath = getKeybindingsPath(context); + if (!keybindingsPath) { + return 0; + } + + let existing: Array> = []; + let rawContent: string | undefined; + if (fs.existsSync(keybindingsPath)) { + try { + rawContent = fs.readFileSync(keybindingsPath, 'utf-8'); + } catch { + console.warn("Doom Code: could not read keybindings.json, skipping magit install."); + return 0; + } + const parsed = readKeybindingsJson(keybindingsPath); + if (parsed === undefined) { + console.warn("Doom Code: could not parse keybindings.json, skipping magit install."); + return 0; + } + existing = parsed; + } + + const toAdd = allMagitRelated.filter((kb) => + !existing.some((e) => e['key'] === kb['key'] && e['command'] === kb['command'] && e['when'] === kb['when']), + ); + + if (toAdd.length === 0) { + return 0; + } + + let output: string; + const newEntries = toAdd.map((kb) => '\t' + JSON.stringify(kb)).join(',\n'); + const block = '\t// #region Doom Code keybindings\n' + newEntries + '\n\t// #endregion Doom Code keybindings'; + + if (rawContent !== undefined && existing.length > 0) { + // Append to existing file — preserve original content and comments. + const lastBracket = rawContent.lastIndexOf(']'); + if (lastBracket !== -1) { + const beforeBracket = rawContent.slice(0, lastBracket).trimEnd(); + const rest = rawContent.slice(lastBracket + 1); + output = beforeBracket + ',\n' + block + '\n]' + rest; + } else { + // Malformed — fall back to full rewrite. + output = "// Place your key bindings in this file to override the defaults\n" + + JSON.stringify([...existing, ...toAdd], null, '\t') + + '\n'; + } + } else { + // File doesn't exist or is empty — write fresh. + output = "// Place your key bindings in this file to override the defaults\n[\n" + + block + + '\n]\n'; + } + + try { + fs.mkdirSync(path.dirname(keybindingsPath), { recursive: true }); + fs.writeFileSync(keybindingsPath, output, 'utf-8'); + } catch (err) { + console.warn("Doom Code: failed to write magit keybindings to keybindings.json:", err); + return 0; + } + + return toAdd.length; +} diff --git a/src/onboarding/onboardingCommands.ts b/src/onboarding/onboardingCommands.ts new file mode 100644 index 0000000..5c81cdd --- /dev/null +++ b/src/onboarding/onboardingCommands.ts @@ -0,0 +1,257 @@ +import * as vscode from 'vscode'; +import { + ApplyDefaultsOptions, + ApplyDefaultsResult, + applyDefaultsToConfiguration, + runInstallFlow, + type VimBindingConflict, + type VimBindingConflictDecision, +} from './install'; +import { cleanStaleKeybindings, installDoomKeybindings } from './keybindingsFile'; +import { cleanStaleSettings, migrateLegacyWhichKeyShowBindings } from './staleCleanup'; + +const KEEP_EXISTING_BINDING_ACTION = 'Keep Existing'; +const OVERWRITE_WITH_DOOM_ACTION = 'Overwrite with Doom'; +const KEEP_ALL_EXISTING_BINDINGS_ACTION = 'Keep All Existing'; +const OVERWRITE_ALL_WITH_DOOM_ACTION = 'Overwrite All with Doom'; + +// --------------------------------------------------------------------------- +// Conflicting extensions that override the same settings Doom Code manages. +// --------------------------------------------------------------------------- +export const CONFLICTING_EXTENSIONS = [ + { + id: "VSpaceCode.vspacecode", + name: "VSpaceCode", + reason: "overrides whichkey.bindings and vim keybindings with its own defaults", + }, +]; + +/** Returns the subset of `CONFLICTING_EXTENSIONS` that are currently installed. */ +export function detectConflictingExtensions(): typeof CONFLICTING_EXTENSIONS { + return CONFLICTING_EXTENSIONS.filter( + (ext) => vscode.extensions.getExtension(ext.id) !== undefined + ); +} + +/** Shows a modal warning for each conflicting extension with an "Open Extensions" action. */ +async function warnAboutConflicts(conflicts: typeof CONFLICTING_EXTENSIONS): Promise { + for (const ext of conflicts) { + const choice = await vscode.window.showWarningMessage( + `Doom Code: "${ext.name}" is installed and ${ext.reason}. This will cause keybinding conflicts. Please uninstall "${ext.name}" and reload.`, + "Open Extensions" + ); + if (choice === "Open Extensions") { + await vscode.commands.executeCommand("workbench.extensions.action.showInstalledExtensions"); + } + } +} + +/** + * Writes install defaults to the user's global settings, skipping user-owned keys. + * Optionally shows a toast summarising applied/skipped/failed counts. + */ +async function applyDefaultsToUserSettings( + defaults: Record, + showResultMessage = false, + options: ApplyDefaultsOptions = {}, +): Promise { + const config = vscode.workspace.getConfiguration(); + const result = await applyDefaultsToConfiguration(config, defaults, vscode.ConfigurationTarget.Global, options); + + if (showResultMessage) { + if (result.total === 0) { + void vscode.window.showWarningMessage("No Doom install defaults are configured in package.json."); + return result; + } + + const parts: string[] = [ + `${result.applied} applied`, + `${result.skipped} skipped (already customized by you)`, + ]; + if (result.unsupported > 0) { + parts.push(`${result.unsupported} not recognized by VS Code`); + } + if (result.failed > 0) { + parts.push(`${result.failed} failed`); + } + const failureDetails = result.failures.length > 0 + ? ` Failures: ${result.failures.slice(0, 3).map((failure) => ( + `${failure.key} (${failure.reason})` + )).join('; ')}${result.failures.length > 3 ? `; +${result.failures.length - 3} more` : ''}.` + : ''; + void vscode.window.showInformationMessage( + `Doom defaults applied to your global User settings: ${parts.join(', ')}.${failureDetails}` + ); + } + + return result; +} + +function summarizeValueForUi(value: unknown, maxLength = 180): string { + let serialized: string; + try { + serialized = JSON.stringify(value); + } catch { + serialized = String(value); + } + + return serialized.length <= maxLength ? serialized : `${serialized.slice(0, maxLength - 1)}…`; +} + +function formatVimBindingChord(before: readonly string[]): string { + return before.join(' '); +} + +function createVimBindingConflictResolver(): ApplyDefaultsOptions['resolveVimBindingConflict'] { + let rememberedDecision: VimBindingConflictDecision | undefined; + + return async (conflict: VimBindingConflict) => { + if (rememberedDecision) { + return rememberedDecision; + } + + const choice = await vscode.window.showWarningMessage( + `Doom Code: ${conflict.settingKey} already contains a binding for ${formatVimBindingChord(conflict.before)}.`, + { + modal: true, + detail: [ + `Existing: ${conflict.existingEntries.map((entry) => summarizeValueForUi(entry)).join(' | ')}`, + `Doom: ${summarizeValueForUi(conflict.defaultEntry)}`, + 'Keep existing preserves your current mapping. Overwrite replaces all conflicting bindings for this chord with Doom\'s default.', + ].join('\n'), + }, + KEEP_EXISTING_BINDING_ACTION, + OVERWRITE_WITH_DOOM_ACTION, + KEEP_ALL_EXISTING_BINDINGS_ACTION, + OVERWRITE_ALL_WITH_DOOM_ACTION, + ); + + if (choice === OVERWRITE_ALL_WITH_DOOM_ACTION) { + rememberedDecision = 'overwrite'; + return 'overwrite'; + } + + if (choice === KEEP_ALL_EXISTING_BINDINGS_ACTION) { + rememberedDecision = 'keep'; + return 'keep'; + } + + if (choice === OVERWRITE_WITH_DOOM_ACTION) { + return 'overwrite'; + } + + return 'keep'; + }; +} + +/** Runs both setting and keybinding cleanup, then shows a summary toast with a "Reload" action. */ +async function runCleanup(context: vscode.ExtensionContext): Promise { + const cleanedSettings = await cleanStaleSettings(); + const removedKeybindings = await cleanStaleKeybindings(context); + + const conflicts = detectConflictingExtensions(); + + const parts: string[] = []; + if (cleanedSettings.length > 0) { + parts.push(`cleaned ${cleanedSettings.length} setting(s)`); + } + if (removedKeybindings > 0) { + parts.push(`removed ${removedKeybindings} stale keybinding(s)`); + } + if (conflicts.length > 0) { + parts.push(`${conflicts.length} conflicting extension(s) detected`); + } + + if (parts.length > 0) { + void vscode.window.showInformationMessage( + `Doom Code cleanup: ${parts.join(', ')}. Please reload the window.`, + "Reload" + ).then((choice: string | undefined) => { + if (choice === "Reload") { + void vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); + } else { + void vscode.window.showInformationMessage("Doom Code: no stale settings or conflicts found."); + } +} + +export interface OnboardingCommandDeps { + /** The `doomInstallDefaults` map applied by `doom.install`. */ + installDefaults: Record; + /** Debounced dashboard re-render, invoked after install/cleanup mutate state. */ + scheduleDashboardRefresh: (delayMs?: number) => void; + /** Opens the start page in `startup` mode (the `doom.dashboard` target). */ + showStartupDashboard: () => Promise; +} + +/** Registers the onboarding commands: `doom.install`, `doom.cleanup`, and `doom.dashboard`. */ +export function register(context: vscode.ExtensionContext, deps: OnboardingCommandDeps): void { + const { installDefaults, scheduleDashboardRefresh, showStartupDashboard } = deps; + + // Manual install command + const installCmd = vscode.commands.registerCommand( + "doom.install", + async () => { + const result = await runInstallFlow( + async () => { + const choice = await vscode.window.showWarningMessage( + "Apply Doom default settings and keybindings to your User settings? Existing user-owned values stay untouched unless you choose to overwrite a conflicting Doom Vim binding.", + { modal: true }, + "Apply" + ); + return choice === "Apply"; + }, + async () => { + await migrateLegacyWhichKeyShowBindings(); + const settingsResult = await applyDefaultsToUserSettings(installDefaults, true, { + resolveVimBindingConflict: createVimBindingConflictResolver(), + }); + const addedKeybindings = await installDoomKeybindings(context); + if (addedKeybindings > 0) { + void vscode.window.showInformationMessage( + `Doom Code: installed ${addedKeybindings} magit keybinding(s) into keybindings.json so the magit dispatch recognises them.` + ); + } + return settingsResult; + }, + ); + + if (!result) { + return; + } + + scheduleDashboardRefresh(0); + } + ); + + // Manual cleanup command + const cleanupCmd = vscode.commands.registerCommand( + "doom.cleanup", + async () => { + const choice = await vscode.window.showWarningMessage( + "This will remove stale settings and keybindings left behind by conflicting extensions (e.g. vspacecode.* commands) from your User settings.json and keybindings.json. Note: keybindings.json will be rewritten — all comments and custom formatting will be lost. This cannot be undone.", + { modal: true }, + "Clean Up" + ); + if (choice !== "Clean Up") { + return; + } + const conflicts = detectConflictingExtensions(); + if (conflicts.length > 0) { + await warnAboutConflicts(conflicts); + } + await runCleanup(context); + scheduleDashboardRefresh(0); + } + ); + + const showDashboardCmd = vscode.commands.registerCommand( + "doom.dashboard", + async () => { + await showStartupDashboard(); + } + ); + + context.subscriptions.push(installCmd, cleanupCmd, showDashboardCmd); +} diff --git a/src/onboarding/staleCleanup.ts b/src/onboarding/staleCleanup.ts new file mode 100644 index 0000000..370624d --- /dev/null +++ b/src/onboarding/staleCleanup.ts @@ -0,0 +1,103 @@ +import * as vscode from 'vscode'; +import { DOOM_STALE_VIM_BINDING_SETTINGS } from './vimBindings'; + +// Stale command prefixes left behind by conflicting extensions. +export const STALE_COMMAND_PREFIXES = ["vspacecode."]; + +/** + * Returns true if `value` (at any depth) contains a string that starts with + * one of the stale command prefixes. + */ +export function containsStaleCommand(value: unknown): boolean { + if (typeof value === 'string') { + return STALE_COMMAND_PREFIXES.some((p) => value.startsWith(p)); + } + if (Array.isArray(value)) { + return value.some(containsStaleCommand); + } + if (value !== null && typeof value === 'object') { + return Object.values(value as Record).some(containsStaleCommand); + } + return false; +} + +/** + * Scan vim keybinding arrays in user settings for stale commands. + * Only removes individual stale entries, preserving all user-defined bindings. + * Returns the list of setting keys that were cleaned. + */ +export async function cleanStaleSettings(): Promise { + const config = vscode.workspace.getConfiguration(); + const keysToCheck = DOOM_STALE_VIM_BINDING_SETTINGS; + + const cleaned: string[] = []; + + for (const key of keysToCheck) { + const inspected = config.inspect(key); + const currentValue = inspected?.globalValue; + if (!Array.isArray(currentValue)) { + continue; + } + + const filtered = currentValue.filter((entry) => !containsStaleCommand(entry)); + if (filtered.length !== currentValue.length) { + await config.update(key, filtered, vscode.ConfigurationTarget.Global); + cleaned.push(key); + } + } + + return cleaned; +} + +/** One-time migration: rewrites `whichkey.show` → `doom.whichKeyShow` in user vim keybindings so SPC still works after install. */ +export async function migrateLegacyWhichKeyShowBindings(): Promise { + const config = vscode.workspace.getConfiguration(); + const keysToCheck = [ + "vim.normalModeKeyBindingsNonRecursive", + "vim.visualModeKeyBindingsNonRecursive", + ]; + + for (const key of keysToCheck) { + const inspected = config.inspect(key); + const currentValue = inspected?.globalValue; + if (!Array.isArray(currentValue)) { + continue; + } + + let changed = false; + const migrated = currentValue.map((entry) => { + if ( + entry !== null + && typeof entry === 'object' + && 'before' in entry + && 'commands' in entry + ) { + const binding = entry as { + before?: unknown; + commands?: unknown; + }; + + if ( + Array.isArray(binding.before) + && binding.before.length === 1 + && binding.before[0] === '' + && Array.isArray(binding.commands) + && binding.commands.length === 1 + && binding.commands[0] === 'whichkey.show' + ) { + changed = true; + return { + ...entry, + commands: ['doom.whichKeyShow'], + }; + } + } + + return entry; + }); + + if (changed) { + await config.update(key, migrated, vscode.ConfigurationTarget.Global); + } + } +} diff --git a/src/terminal/terminalCommands.ts b/src/terminal/terminalCommands.ts new file mode 100644 index 0000000..7e11ab0 --- /dev/null +++ b/src/terminal/terminalCommands.ts @@ -0,0 +1,102 @@ +import * as vscode from 'vscode'; + +const VTERM_NAME = '*vterm*'; +const VTERM_PREFIX = '*vterm*'; + +/** + * AI-CLI editor terminals. Each entry registers a command that opens an editor + * terminal with a fixed name and launches the matching CLI. The name set also + * seeds {@link EDITOR_TERMINAL_NAMES} so these terminals are recognised by + * {@link isVtermName} and excluded from panel-terminal switching (`SPC o t`). + */ +interface CliTerminal { + commandId: string; + name: string; +} + +const CLI_TERMINALS: CliTerminal[] = [ + { commandId: 'doom.openClaudeCli', name: 'claude' }, + { commandId: 'doom.openCopilotCli', name: 'copilot' }, + { commandId: 'doom.openCodexCli', name: 'codex' }, +]; + +/** + * Names that identify editor-group terminals (as opposed to panel terminals), + * derived from the CLI table. + */ +const EDITOR_TERMINAL_NAMES = new Set(CLI_TERMINALS.map((cli) => cli.name)); + +const isVtermName = (name: string) => + name === VTERM_NAME + || name.startsWith(`${VTERM_PREFIX}<`) + || EDITOR_TERMINAL_NAMES.has(name.toLowerCase()); + +/** + * Opens an AI tool CLI in an editor terminal with a fixed name. + * The consistent name lets `isVtermName()` exclude it from panel terminal + * switching (SPC o t) and lets users reliably find the CLI terminal by name. + * Creates a new terminal each trigger (no reuse). + */ +async function openCliTerminal(name: string): Promise { + const terminal = vscode.window.createTerminal({ + name, + location: vscode.TerminalLocation.Editor, + }); + terminal.show(); + await vscode.commands.executeCommand('workbench.action.terminal.renameWithArg', { name }); + terminal.sendText(name); +} + +/** Registers the editor/AI-CLI terminal and panel-terminal commands. */ +export function register(context: vscode.ExtensionContext): void { + const managedVtermSet = new Set(); + + /** Creates a named editor-group terminal so `doom.openPanelTerminal` can exclude it by name. */ + const createTerminalEditorCmd = vscode.commands.registerCommand( + "doom.createTerminalEditor", + async () => { + const vtermCount = vscode.window.terminals.filter((t) => isVtermName(t.name)).length; + const name = vtermCount === 0 ? VTERM_NAME : `${VTERM_PREFIX}<${vtermCount + 1}>`; + const terminal = vscode.window.createTerminal({ + name, + location: vscode.TerminalLocation.Editor, + }); + managedVtermSet.add(terminal); + terminal.show(); + // Lock the title so the shell (bash PROMPT_COMMAND / PS1) cannot override it + await vscode.commands.executeCommand('workbench.action.terminal.renameWithArg', { name }); + } + ); + + const cliTerminalCmds = CLI_TERMINALS.map((cli) => + vscode.commands.registerCommand(cli.commandId, () => openCliTerminal(cli.name)), + ); + + /** + * Opens the panel terminal without disturbing terminals in editor groups. + * Editor terminals created via `doom.createTerminalEditor` are named `*vterm*` or `*vterm*`. + * Known CLI editor terminals such as `claude`, `copilot`, and `codex` are also excluded by name. + * Panel terminals are anything not carrying those names. + * Falls back to creating a new panel terminal only when none exist. + * Uses show(true) to pre-select the terminal, then workbench.view.terminal to reliably + * open the panel — terminal.show() alone doesn't guarantee the panel opens. + */ + const openPanelTerminalCmd = vscode.commands.registerCommand( + "doom.openPanelTerminal", + () => { + const panelTerminals = vscode.window.terminals.filter((t) => !isVtermName(t.name)); + + if (panelTerminals.length > 0) { + panelTerminals[panelTerminals.length - 1].show(false); + } else { + vscode.window.createTerminal({ location: vscode.TerminalLocation.Panel }).show(false); + } + } + ); + + context.subscriptions.push( + createTerminalEditorCmd, + ...cliTerminalCmds, + openPanelTerminalCmd, + ); +} diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 878dc8c..5655988 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -2,7 +2,6 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import { computeWorkspaceHistoryUpdate, - resolveWindowDeleteAction, selectReloadWorkspaceTarget, type StoredWorkspaceTarget, } from '../extension'; @@ -29,6 +28,7 @@ import { resolveWindowLeftTarget, resolveWindowRightTarget, } from '../window/mru'; +import { resolveWindowDeleteAction } from '../window/windowCommands'; import { applyTrackedUiContextCommand, evaluateWhenExpression, diff --git a/src/window/windowCommands.ts b/src/window/windowCommands.ts new file mode 100644 index 0000000..5ab4458 --- /dev/null +++ b/src/window/windowCommands.ts @@ -0,0 +1,108 @@ +import * as vscode from 'vscode'; +import { DoomWhichKeyMenu } from '../whichkey/menu'; +import { focusEditorGroup, focusWindowDown, focusWindowLeft, focusWindowRight, focusWindowUp } from './mru'; + +export type WindowDeleteAction = 'closeGroup' | 'closePanel' | 'moveTerminalEditorToPanelAndCloseGroup'; + +/** + * Pure function: determines the correct `doom.windowDelete` action based on focus context. + * Terminal panel focus → close panel. Terminal editor tab → move back to panel first. Otherwise → close group. + */ +export function resolveWindowDeleteAction( + terminalFocus: boolean, + activeTerminalEditor: boolean, +): WindowDeleteAction { + if (terminalFocus && !activeTerminalEditor) { + return 'closePanel'; + } + + if (activeTerminalEditor) { + return 'moveTerminalEditorToPanelAndCloseGroup'; + } + + return 'closeGroup'; +} + +export interface WindowCommandDeps { + whichKeyMenu: DoomWhichKeyMenu; +} + +/** Registers `doom.windowDelete` and the window focus/split commands. */ +export function register(context: vscode.ExtensionContext, deps: WindowCommandDeps): void { + const { whichKeyMenu } = deps; + + const windowDeleteCmd = vscode.commands.registerCommand( + "doom.windowDelete", + async () => { + const activeGroup = vscode.window.tabGroups.activeTabGroup; + const activeTerminalEditor = activeGroup.activeTab?.input instanceof vscode.TabInputTerminal; + const action = resolveWindowDeleteAction( + whichKeyMenu.showContext.terminalFocus, + activeTerminalEditor, + ); + + if (action === 'closePanel') { + await vscode.commands.executeCommand('workbench.action.closePanel'); + return; + } + + if (action === 'moveTerminalEditorToPanelAndCloseGroup') { + await vscode.commands.executeCommand('workbench.action.terminal.moveToTerminalPanel'); + await focusEditorGroup(activeGroup.viewColumn); + await vscode.commands.executeCommand('workbench.action.closeGroup'); + return; + } + + // Use the group that was active when whichkey opened (preWhichKeyEditorGroupColumn is set + // during whichkey command execution and undefined for direct invocations). This avoids + // relying on workbench.action.closeGroup honouring focus, which VS Code does not guarantee + // after the whichkey panel closes. + const targetColumn = whichKeyMenu.preWhichKeyEditorGroupColumn ?? activeGroup.viewColumn; + const groupToClose = vscode.window.tabGroups.all.find(g => g.viewColumn === targetColumn) + ?? activeGroup; + await vscode.window.tabGroups.close(groupToClose); + } + ); + + const windowLeftCmd = vscode.commands.registerCommand( + "doom.windowLeft", + async () => { + const activeGroup = vscode.window.tabGroups.activeTabGroup; + const explorerVisible = whichKeyMenu.trackedUiContext.explorerViewletVisible; + await focusWindowLeft(activeGroup, vscode.window.tabGroups.all, explorerVisible, whichKeyMenu.showContext.explorerFocused); + } + ); + + const windowRightCmd = vscode.commands.registerCommand( + "doom.windowRight", + async () => { + const activeGroup = vscode.window.tabGroups.activeTabGroup; + await focusWindowRight(whichKeyMenu.showContext.explorerFocused, activeGroup, vscode.window.tabGroups.all); + } + ); + + const windowUpCmd = vscode.commands.registerCommand( + "doom.windowUp", + async () => { + const panelFocused = whichKeyMenu.showContext.terminalFocus && whichKeyMenu.showContext.terminalPanelOpen; + await focusWindowUp(panelFocused); + } + ); + + const windowDownCmd = vscode.commands.registerCommand( + "doom.windowDown", + async () => { + const activeGroup = vscode.window.tabGroups.activeTabGroup; + const panelVisible = whichKeyMenu.trackedUiContext.activePanel !== ''; + await focusWindowDown(activeGroup, panelVisible); + } + ); + + context.subscriptions.push( + windowDeleteCmd, + windowLeftCmd, + windowRightCmd, + windowUpCmd, + windowDownCmd, + ); +} From 5cb8a2404bc3a1a210a3ea331f697ff12f13d76a Mon Sep 17 00:00:00 2001 From: Hendrik Rudek Date: Wed, 17 Jun 2026 16:31:20 +0200 Subject: [PATCH 09/12] fix: align package.json command contributions with registered commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare all user-invokable commands in contributes.commands; keep internal routing and mid-keystroke mode-state commands (whichKeyHide, sidebarHide, panelHide, terminalEscape*) out of the palette. Replace the hand-picked command lists in extension.test.ts with a dynamic cross-check that reads contributes.commands and contributes.keybindings from package.json and asserts every referenced command is registered — drift now fails CI automatically. Document the declaration rule in CONTRIBUTING.md. --- CONTRIBUTING.md | 16 ++++++-- package.json | 45 ++++++++++++++------- src/test/extension.test.ts | 83 +++++++++++++++----------------------- 3 files changed, 75 insertions(+), 69 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a1245..f9b9d53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -164,10 +164,18 @@ flake.nix # Nix development environment ### Adding a New Command -1. Register the command in `src/extension.ts` → `activate()` -2. Add to `package.json` → `contributes.commands` -3. Add keybinding in `package.json` → `contributes.keybindings` (optional) -4. Document in `README.md` +1. Register the command in the appropriate module's `register()` function (or in `src/extension.ts` → `activate()` for commands that don't belong to a dedicated module). +2. Decide whether to declare it in `package.json` → `contributes.commands` using the rule below. +3. Add a keybinding in `package.json` → `contributes.keybindings` (optional). +4. Document in `README.md`. + +#### Command declaration rule + +**Declare** a command in `contributes.commands` (with a `title` and `"category": "Doom"`) if it is **user-invokable**: it has a meaningful human-readable title, should appear in the Command Palette, or represents a named action that a user might discover and run directly. + +**Do not declare** commands that are pure internal implementation details — keybinding-only routing helpers, UI-state toggles triggered only by the extension itself, or mid-keystroke mode-state commands that have no standalone meaning when invoked from the palette (e.g. `doom.whichKeyHide`, `doom.sidebarHide`, `doom.panelHide`, `doom.terminalEscapePrefix`, `doom.terminalEscapeSpace`, `doom.terminalSendEscape`). + +The test `activates and registers Doom commands` in `src/test/extension.test.ts` enforces this contract automatically: it asserts that every `contributes.commands` entry and every `doom.*` command in `contributes.keybindings` is actually registered by the extension. CI will catch any drift. ### Updating Keybindings diff --git a/package.json b/package.json index 38d8a85..1888251 100644 --- a/package.json +++ b/package.json @@ -1624,21 +1624,6 @@ "title": "Show Which Key", "category": "Doom" }, - { - "command": "doom.terminalEscapePrefix", - "title": "Terminal Escape Prefix", - "category": "Doom" - }, - { - "command": "doom.terminalEscapeSpace", - "title": "Terminal Escape + Space: Show Which Key", - "category": "Doom" - }, - { - "command": "doom.terminalSendEscape", - "title": "Terminal Escape + Escape: Send Escape", - "category": "Doom" - }, { "command": "doom.whichKeyShowBindings", "title": "Show Which-Key Bindings", @@ -1683,6 +1668,36 @@ "command": "doom.triggerKey", "title": "Which-Key: Trigger Key", "category": "Doom" + }, + { + "command": "doom.windowDelete", + "title": "Delete Window", + "category": "Doom" + }, + { + "command": "doom.createTerminalEditor", + "title": "Open Terminal in Editor", + "category": "Doom" + }, + { + "command": "doom.openClaudeCli", + "title": "Open Claude CLI", + "category": "Doom" + }, + { + "command": "doom.openCopilotCli", + "title": "Open Copilot CLI", + "category": "Doom" + }, + { + "command": "doom.openCodexCli", + "title": "Open Codex CLI", + "category": "Doom" + }, + { + "command": "doom.openPanelTerminal", + "title": "Open Panel Terminal", + "category": "Doom" } ], "keybindings": [ diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 5655988..8e6b7d6 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -37,35 +37,23 @@ import { suite('Extension Test Suite', () => { const extensionId = 'bearylabs.doom'; - const expectedRuntimeCommands = [ - 'doom.cleanup', - 'doom.fuzzySearchActiveTextEditor', - 'doom.fuzzySearchWorkspace', - 'doom.install', - 'doom.reloadLastSession', - 'doom.dashboard', - 'doom.showOpenEditors', - 'doom.whichKeyHide', - 'doom.whichKeyShow', - 'doom.whichKeyShowBindings', - 'doom.windowLeft', - 'doom.windowRight', - 'doom.windowMru', - ] as const; - const expectedContributedCommands = [ - 'doom.cleanup', - 'doom.fuzzySearchActiveTextEditor', - 'doom.fuzzySearchWorkspace', - 'doom.install', - 'doom.reloadLastSession', - 'doom.dashboard', - 'doom.showOpenEditors', - 'doom.whichKeyShow', - 'doom.whichKeyShowBindings', - 'doom.windowLeft', - 'doom.windowRight', - 'doom.windowMru', - ] as const; + + type PackageJson = { + contributes?: { + commands?: Array<{ command: string }>; + keybindings?: Array<{ command?: string }>; + configuration?: { + properties?: Record; + }; + configurationDefaults?: Record; + }; + extensionDependencies?: string[]; + extensionPack?: string[]; + }; + + function getDoomPackageJson(extension: vscode.Extension): PackageJson { + return extension.packageJSON as PackageJson; + } test('activates and registers Doom commands', async () => { const extension = vscode.extensions.getExtension(extensionId); @@ -73,9 +61,21 @@ suite('Extension Test Suite', () => { await extension.activate(); - const commands = await vscode.commands.getCommands(true); - for (const command of expectedRuntimeCommands) { - assert.ok(commands.includes(command), `Expected command ${command} to be registered`); + const registered = new Set(await vscode.commands.getCommands(true)); + const pkg = getDoomPackageJson(extension); + + // Every command declared in contributes.commands must be registered. + const declaredCommands = pkg.contributes?.commands?.map((c) => c.command) ?? []; + for (const id of declaredCommands) { + assert.ok(registered.has(id), `contributes.commands declares "${id}" but it is not registered`); + } + + // Every doom.* command referenced in contributes.keybindings must be registered. + const keybindingCommands = (pkg.contributes?.keybindings ?? []) + .map((kb) => kb.command) + .filter((cmd): cmd is string => typeof cmd === 'string' && cmd.startsWith('doom.')); + for (const id of keybindingCommands) { + assert.ok(registered.has(id), `contributes.keybindings references "${id}" but it is not registered`); } }); @@ -83,24 +83,7 @@ suite('Extension Test Suite', () => { const extension = vscode.extensions.getExtension(extensionId); assert.ok(extension, `Expected extension ${extensionId} to be installed`); - const packageJson = extension.packageJSON as { - contributes?: { - commands?: Array<{ command: string }>; - configuration?: { - properties?: Record; - }; - configurationDefaults?: Record; - }; - extensionDependencies?: string[]; - extensionPack?: string[]; - }; - - const contributedCommands = new Set( - packageJson.contributes?.commands?.map((entry) => entry.command) ?? [] - ); - for (const command of expectedContributedCommands) { - assert.ok(contributedCommands.has(command), `Expected command ${command} in package.json contributes.commands`); - } + const packageJson = getDoomPackageJson(extension); assert.strictEqual( packageJson.contributes?.configuration?.properties?.['doom.whichKey.menuStyle']?.default, From 55c664dec799f64773ef13ea32317c6579925faa Mon Sep 17 00:00:00 2001 From: bearylabs Date: Wed, 17 Jun 2026 18:54:47 +0200 Subject: [PATCH 10/12] refactor: back workspace search with doom-workspace ripgrep command Replace the in-process WorkspaceFileIndex line cache with debounced, cancellable calls to doom-workspace.searchText. Drops workspaceCache, loadSequence, the file-size limit, and background preloading; results now stream from ripgrep/git grep on the workspace host. --- src/extension.ts | 2 +- src/panel/shared.ts | 3 +- src/search/search.ts | 159 +++++++++++++++++++++---------------------- 3 files changed, 78 insertions(+), 86 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 4d4ebb7..eece0a9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -436,7 +436,7 @@ export function activate(context: vscode.ExtensionContext) { ]; const workspaceFileIndex = new WorkspaceFileIndex(); context.subscriptions.push(workspaceFileIndex); - const searchPanel = new DoomSearchPanel(workspaceFileIndex); + const searchPanel = new DoomSearchPanel(); /** Opens a project folder in the current window and suppresses the dashboard on the next activation. */ const openProjectAndSkipDashboard = async (projectUri: vscode.Uri): Promise => { diff --git a/src/panel/shared.ts b/src/panel/shared.ts index e55ae40..163249f 100644 --- a/src/panel/shared.ts +++ b/src/panel/shared.ts @@ -164,14 +164,13 @@ export class DoomSharedPanel implements vscode.WebviewViewProvider { await this.showMode('search', this.searchPanel); } - /** Opens workspace search, then kicks off file indexing after the panel is visible. No-op if no folder is open. */ + /** Opens workspace search. No-op if no folder is open. */ async showWorkspaceSearch(): Promise { if (!this.searchPanel.prepareShowWorkspace()) { return; } await this.showMode('search', this.searchPanel); - await this.searchPanel.loadPreparedWorkspaceItems(); } /** Opens the buffer/open-editors picker. */ diff --git a/src/search/search.ts b/src/search/search.ts index a7282e1..1663184 100644 --- a/src/search/search.ts +++ b/src/search/search.ts @@ -1,7 +1,12 @@ import * as vscode from 'vscode'; import { DoomWebviewController } from '../panel/controller'; import { createNonce, createPanelHtml, substringMatch } from '../panel/helpers'; -import { WorkspaceFileIndex } from './workspaceFileIndex'; + +interface WorkspaceTextSearchResult { + rel: string; + line: number; + text: string; +} const MAX_RESULTS = 200; @@ -171,22 +176,20 @@ export class DoomSearchPanel extends DoomWebviewController { protected readonly visibleContextKey = DoomSearchPanel.visibleContextKey; private static readonly workspaceExcludeGlob = '**/{.git,node_modules,out,dist,coverage,build,.next}/**'; - private static readonly workspaceFileSizeLimit = 1024 * 1024; private accepted = false; private currentItems: SearchItem[] = []; private filteredItems: SearchMatch[] = []; - private loadSequence = 0; private loading = false; private mode: SearchMode = 'editor'; + private resultsCapped = false; + private searchCanceller: vscode.CancellationTokenSource | undefined; + private searchDebounceTimer: ReturnType | undefined; private startingSelection: vscode.Selection | undefined; private targetEditor: vscode.TextEditor | undefined; - private workspaceCache: SearchItem[] | undefined; - constructor(private readonly fileIndex: WorkspaceFileIndex) { + constructor() { super(); - // Drop the cached workspace line index whenever the workspace tree changes. - this.fileIndex.onCacheInvalidated(() => { this.workspaceCache = undefined; }); } /** Switches to editor mode and seeds search state from the active editor. Returns false if no editor is open. */ @@ -201,11 +204,6 @@ export class DoomSearchPanel extends DoomWebviewController { return this.initializeWorkspaceSearch({ notifyWhenMissing: true, resetQuery: true }); } - /** Kicks off workspace file loading in the background after the panel is shown. */ - async loadPreparedWorkspaceItems(): Promise { - await this.loadWorkspaceItems(); - } - protected get itemCount(): number { return this.filteredItems.length; } @@ -260,7 +258,7 @@ export class DoomSearchPanel extends DoomWebviewController { } /** Re-initializes and re-renders when the panel becomes visible again — handles both modes. */ - private async refreshVisibleSearch(): Promise { + private refreshVisibleSearch(): void { this.updateViewMetadata(); if (this.mode === 'workspace') { if (!this.initializeWorkspaceSearch({ resetQuery: true })) { @@ -268,7 +266,6 @@ export class DoomSearchPanel extends DoomWebviewController { } this.render(); - await this.loadWorkspaceItems(); return; } @@ -333,10 +330,17 @@ export class DoomSearchPanel extends DoomWebviewController { } /** - * Resets state for a fresh workspace search. Items are empty until `loadWorkspaceItems` resolves. + * Resets state for a fresh workspace search. Items are empty until a query triggers `runWorkspaceSearch`. * Returns false (and optionally notifies) when no workspace folder exists. */ private initializeWorkspaceSearch(options: SearchOptions = {}): boolean { + this.searchCanceller?.cancel(); + this.searchCanceller = undefined; + if (this.searchDebounceTimer !== undefined) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = undefined; + } + if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { this.accepted = false; this.activeIndex = 0; @@ -358,7 +362,7 @@ export class DoomSearchPanel extends DoomWebviewController { this.activeIndex = 0; this.currentItems = []; this.filteredItems = []; - this.loading = true; + this.loading = false; this.startingSelection = undefined; this.targetEditor = undefined; if (options.resetQuery) { @@ -367,104 +371,93 @@ export class DoomSearchPanel extends DoomWebviewController { return true; } - /** Splits a document into trimmed, non-empty line items for editor-mode search. */ - private buildDocumentItems(document: vscode.TextDocument): SearchItem[] { - const lines = document.getText().split(/\r?\n/); - - return lines - .map((text, index) => ({ - line: index, - lineLabel: String(index + 1), - searchText: text.trim().toLowerCase(), - text: text.trim(), - })) - .filter((item) => item.text.length > 0); - } + /** Overrides base query handler to debounce and delegate to `findTextInFiles` in workspace mode. */ + protected async onQuery(query: string): Promise { + this.query = query; + if (this.mode !== 'workspace') { + this.filterItems(); + this.render(); + await this.afterRender(false); + return; + } - private static readonly loadBatchSize = 20; + this.searchCanceller?.cancel(); + this.searchCanceller = undefined; + if (this.searchDebounceTimer !== undefined) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = undefined; + } - /** - * Finds all workspace files, loads them in batches, then caches the result. - * Uses `loadSequence` to abandon stale loads when a new search is triggered mid-flight. - * Cache is invalidated by a filesystem watcher on any create/delete/change. - */ - private async loadWorkspaceItems(): Promise { - if (this.workspaceCache) { + if (query.trim().length < 2) { this.loading = false; - this.currentItems = this.workspaceCache; + this.currentItems = []; this.filterItems(); this.render(); return; } - const loadId = ++this.loadSequence; this.loading = true; + this.currentItems = []; this.render(); - const files = await vscode.workspace.findFiles('**/*', DoomSearchPanel.workspaceExcludeGlob); - const items: SearchItem[] = []; - - for (let i = 0; i < files.length; i += DoomSearchPanel.loadBatchSize) { - if (loadId !== this.loadSequence || this.mode !== 'workspace') { - return; - } - - const batch = files.slice(i, i + DoomSearchPanel.loadBatchSize); - const results = await Promise.allSettled(batch.map((uri) => this.loadFileItems(uri))); + this.searchDebounceTimer = setTimeout(() => { + this.searchDebounceTimer = undefined; + void this.runWorkspaceSearch(query); + }, 200); + } - for (const result of results) { - if (result.status === 'fulfilled') { - items.push(...result.value); - } + /** Runs a text search via the `doom-workspace` sidecar, discards result if superseded. */ + private async runWorkspaceSearch(query: string): Promise { + const canceller = new vscode.CancellationTokenSource(); + this.searchCanceller = canceller; + + const rootUri = vscode.workspace.workspaceFolders?.[0]?.uri; + let rawResults: WorkspaceTextSearchResult[] = []; + + if (rootUri) { + try { + rawResults = await vscode.commands.executeCommand( + 'doom-workspace.searchText', + rootUri.toString(), + query, + MAX_RESULTS, + ) ?? []; + } catch { + // doom-workspace not installed or search failed — show empty results. } } - if (loadId !== this.loadSequence || this.mode !== 'workspace') { + if (canceller.token.isCancellationRequested) { + canceller.dispose(); return; } - this.workspaceCache = items; + canceller.dispose(); + this.searchCanceller = undefined; + this.resultsCapped = rawResults.length >= MAX_RESULTS; this.loading = false; - this.currentItems = items; + this.currentItems = rawResults.map(r => ({ + uri: vscode.Uri.joinPath(rootUri!, r.rel), + fileLabel: r.rel, + line: r.line, + lineLabel: String(r.line + 1), + text: r.text, + searchText: r.text.toLowerCase(), + })); this.filterItems(); this.render(); } - /** Loads a single file's lines, silently skipping files that are oversized, non-file, or unreadable. */ - private async loadFileItems(uri: vscode.Uri): Promise { - let stat: vscode.FileStat | undefined; - try { - stat = await vscode.workspace.fs.stat(uri); - } catch (err) { - console.warn('[DoomFuzzySearch] stat failed:', err); - return []; - } - if (!stat || stat.size > DoomSearchPanel.workspaceFileSizeLimit || stat.type !== vscode.FileType.File) { - return []; - } - - try { - const document = await vscode.workspace.openTextDocument(uri); - return this.buildWorkspaceItems(document); - } catch (err) { - console.warn('[DoomFuzzySearch] openTextDocument failed:', err); - return []; - } - } - - /** Same as `buildDocumentItems` but attaches `fileLabel` and `uri` for workspace-mode navigation. */ - private buildWorkspaceItems(document: vscode.TextDocument): SearchItem[] { + /** Splits a document into trimmed, non-empty line items for editor-mode search. */ + private buildDocumentItems(document: vscode.TextDocument): SearchItem[] { const lines = document.getText().split(/\r?\n/); - const fileLabel = vscode.workspace.asRelativePath(document.uri, false); return lines .map((text, index) => ({ - fileLabel, line: index, lineLabel: String(index + 1), searchText: text.trim().toLowerCase(), text: text.trim(), - uri: document.uri, })) .filter((item) => item.text.length > 0); } From 7a94febcd068da681e96d42708b19156d6e6a752 Mon Sep 17 00:00:00 2001 From: bearylabs Date: Wed, 17 Jun 2026 18:54:48 +0200 Subject: [PATCH 11/12] fix: resolve which-key focus race on fast-path chord resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close(): skip closePanel and focus-restore when the panel was never revealed (view not visible), so deferred editor focus no longer races the panel.focus of the command that runs next. setActiveController(): always detach the previous controller, even when the same one is reused, so its ready flag resets before showMode's re-attach replaces the webview HTML — blocking render() until the new webview signals ready. --- src/panel/shared.ts | 11 +++++------ src/whichkey/menu.ts | 7 +++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/panel/shared.ts b/src/panel/shared.ts index 163249f..ff80446 100644 --- a/src/panel/shared.ts +++ b/src/panel/shared.ts @@ -255,13 +255,12 @@ export class DoomSharedPanel implements vscode.WebviewViewProvider { await this.syncVisibilityContexts(true); } - /** Swaps the active controller, detaching the previous one first. No-op if same controller is reused. */ + /** + * Swaps the active controller, detaching the previous one first. Detaches even when the same + * controller is reused so its `ready` flag is reset — `showMode`'s subsequent re-attach replaces + * the webview HTML, and without the reset `render()` could fire before the new JS loads. + */ private setActiveController(mode: SharedPanelMode, controller: SharedPanelController): void { - if (this.activeController === controller) { - this.activeMode = mode; - return; - } - this.activeController?.detachFromView(); this.activeController = controller; this.activeMode = mode; diff --git a/src/whichkey/menu.ts b/src/whichkey/menu.ts index 64b3df0..f71bc75 100644 --- a/src/whichkey/menu.ts +++ b/src/whichkey/menu.ts @@ -993,6 +993,13 @@ export class DoomWhichKeyMenu { this.hostPendingKeys = []; void this.view?.webview.postMessage({ type: 'hide' }); await this.updateVisibilityContext(false); + // Fast path: the chord resolved before the panel was revealed, so the doom panel + // was never shown. Skip closePanel/focus-restore — there is nothing to close and the + // deferred focus would race the subsequent panel.focus of whatever command runs next. + if (!this.view?.visible) { + return; + } + if (this.trackedContext.activePanel === 'terminal') { await vscode.commands.executeCommand('workbench.action.terminal.focus'); if (!this.currentShowContext.terminalFocus) { From bdbf0100d3f87c3044a3c1a68bce4f253b809cf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 09:15:19 +0000 Subject: [PATCH 12/12] Format readDirectory size/permissions in findFile via shared helpers The doom-workspace.readDirectory command now returns raw size/mode numbers instead of pre-formatted strings. Format them here through formatFileSize and formatPermissions from panel/helpers.ts, matching how the project file picker already handles listProjectFiles. Display output is unchanged. --- src/search/findFile.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/search/findFile.ts b/src/search/findFile.ts index 7d85495..54cb4e7 100644 --- a/src/search/findFile.ts +++ b/src/search/findFile.ts @@ -2,7 +2,7 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { DoomWebviewController, type PanelWebviewMessage } from '../panel/controller'; -import { createFilePickerHtml, createNonce, formatRelativeTime, normalizePath, tildeCollapse, tildeExpand } from '../panel/helpers'; +import { createFilePickerHtml, createNonce, formatFileSize, formatPermissions, formatRelativeTime, normalizePath, tildeCollapse, tildeExpand } from '../panel/helpers'; import { SelectionHistory } from './selectionHistory'; // --------------------------------------------------------------------------- @@ -12,9 +12,9 @@ import { SelectionHistory } from './selectionHistory'; interface DirectoryEntry { name: string; isDir: boolean; - size: string; + size: number | undefined; mtime: number | undefined; - permissions: string; + mode: number | undefined; } interface FindFileItem { @@ -216,16 +216,16 @@ export class DoomFindFilePanel extends DoomWebviewController { this.currentDir === '/' ? `/${name}` : `${this.currentDir}/${name}`; for (const entry of entries) { - const { name, isDir, size, mtime: lastModifiedMs, permissions } = entry; + const { name, isDir, size, mtime: lastModifiedMs, mode } = entry; const displayName = isDir ? name + '/' : name; const item: FindFileItem = { isDir, lastModifiedMs, name: displayName, fsPath: joinPath(name), - permissions, + permissions: mode !== undefined ? formatPermissions(mode) : '', searchText: displayName.toLowerCase(), - size, + size: size !== undefined ? formatFileSize(size) : '', }; if (isDir) { dirs.push(item);