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 b4c5cbc..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", @@ -1670,38 +1655,48 @@ "category": "Doom" }, { - "command": "doom.projectFileMoveDown", - "title": "Project File: Move Selection Down", + "command": "doom.showRecentProjects", + "title": "Show Recent Projects", "category": "Doom" }, { - "command": "doom.projectFileMoveUp", - "title": "Project File: Move Selection Up", + "command": "doom.findFile", + "title": "Find File", "category": "Doom" }, { - "command": "doom.showRecentProjects", - "title": "Show Recent Projects", + "command": "doom.triggerKey", + "title": "Which-Key: Trigger Key", "category": "Doom" }, { - "command": "doom.recentProjectsMoveDown", - "title": "Recent Projects: Move Selection Down", + "command": "doom.windowDelete", + "title": "Delete Window", "category": "Doom" }, { - "command": "doom.recentProjectsMoveUp", - "title": "Recent Projects: Move Selection Up", + "command": "doom.createTerminalEditor", + "title": "Open Terminal in Editor", "category": "Doom" }, { - "command": "doom.findFile", - "title": "Find File", + "command": "doom.openClaudeCli", + "title": "Open Claude CLI", "category": "Doom" }, { - "command": "doom.triggerKey", - "title": "Which-Key: Trigger Key", + "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" } ], @@ -2877,36 +2872,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 cef09f1..8d9039f 100644 --- a/src/buffers/openEditors.ts +++ b/src/buffers/openEditors.ts @@ -1,8 +1,11 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; -import { createNonce, formatFileSize, fuzzyMatch, 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; + // --------------------------------------------------------------------------- // Open editor models // --------------------------------------------------------------------------- @@ -49,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}`; @@ -481,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 { @@ -514,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; @@ -562,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. */ @@ -586,7 +652,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. */ @@ -643,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) => { @@ -656,12 +722,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 ?? [], @@ -685,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; @@ -858,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) => ({ @@ -888,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 2ade893..eece0a9 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,23 +12,33 @@ 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'; -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'; +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'; @@ -42,10 +51,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 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'; + +const TERMINAL_ESCAPE_TIMEOUT_MS = 2000; +const DASHBOARD_REFRESH_DEBOUNCE_MS = 50; export interface StoredWorkspaceTarget { label: string; @@ -150,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( @@ -263,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 // --------------------------------------------------------------------------- @@ -328,361 +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 = [ - "vim.normalModeKeyBindingsNonRecursive", - "vim.visualModeKeyBindingsNonRecursive", - "vim.normalModeKeyBindings", - "vim.visualModeKeyBindings", - ]; - - 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 { - 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) // --------------------------------------------------------------------------- @@ -699,12 +317,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_STALE_VIM_BINDING_SETTINGS; const hasStaleSettings = keysToCheck.some((key) => { const inspected = config.inspect(key); @@ -732,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; @@ -842,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 // --------------------------------------------------------------------------- @@ -914,19 +434,21 @@ export function activate(context: vscode.ExtensionContext) { DASHBOARD_OPEN_ON_ACTIVATION_SETTING, ...Object.keys(installDefaults), ]; - const fuzzySearchPanel = new DoomFuzzySearchPanel(); + const workspaceFileIndex = new WorkspaceFileIndex(); + context.subscriptions.push(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 => { 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; // 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; } @@ -951,12 +473,12 @@ 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( whichKeyMenu, - fuzzySearchPanel, + searchPanel, openEditorsPanel, whichKeyBindingsPanel, projectFilePanel, @@ -964,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", @@ -1097,7 +561,7 @@ export function activate(context: vscode.ExtensionContext) { terminalEscapeTimer = setTimeout(() => { terminalEscapeTimer = undefined; void vscode.commands.executeCommand('setContext', 'doom.terminalEscapeMode', false); - }, 2000); + }, TERMINAL_ESCAPE_TIMEOUT_MS); } ); @@ -1135,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') { @@ -1349,20 +652,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', () => { @@ -1370,34 +659,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", () => { @@ -1436,8 +697,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); @@ -1469,9 +731,6 @@ export function activate(context: vscode.ExtensionContext) { })(); context.subscriptions.push( - installCmd, - cleanupCmd, - showDashboardCmd, reloadLastSessionCmd, whichKeyCmd, whichKeyBindingsCmd, @@ -1482,30 +741,14 @@ export function activate(context: vscode.ExtensionContext) { terminalSendEscapeCmd, sidebarHideCmd, panelHideCmd, - createTerminalEditorCmd, - openClaudeCliCmd, - openCopilotCliCmd, - openCodexCliCmd, - openPanelTerminalCmd, - windowDeleteCmd, - windowLeftCmd, - windowRightCmd, - windowUpCmd, - windowDownCmd, configurationChangeListener, fuzzySearchCmd, workspaceFuzzySearchCmd, openEditorsCmd, allOpenEditorsCmd, findFileCmd, - findFileMoveDownCmd, - findFileMoveUpCmd, findFileInProjectCmd, showRecentProjectsCmd, - recentProjectsMoveDownCmd, - recentProjectsMoveUpCmd, - projectFileMoveDownCmd, - projectFileMoveUpCmd, sharedPanelViewProvider, new vscode.Disposable(() => { if (dashboardRefreshTimer) { 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/onboarding/vimBindings.ts b/src/onboarding/vimBindings.ts index 60eb520..31e0cb8 100644 --- a/src/onboarding/vimBindings.ts +++ b/src/onboarding/vimBindings.ts @@ -7,6 +7,12 @@ 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; + type VimBindingEntry = { before?: unknown; commands?: unknown; @@ -34,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/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 2a3615d..963083e 100644 --- a/src/panel/helpers.ts +++ b/src/panel/helpers.ts @@ -63,18 +63,23 @@ 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; } -/** 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. */ -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 +105,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; } @@ -147,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
- - + +
@@ -349,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; @@ -380,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; @@ -394,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 + '"]'); @@ -452,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; - } + const resultItems = items.filter((item) => item.type !== 'header'); - 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; - } - - 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; } @@ -527,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/panel/shared.ts b/src/panel/shared.ts index 38b4542..ff80446 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,20 @@ 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. */ + /** Opens workspace search. 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); } /** Opens the buffer/open-editors picker. */ @@ -256,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; @@ -277,7 +275,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/findFile.ts b/src/search/findFile.ts index 0f0368d..54cb4e7 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, formatFileSize, formatPermissions, formatRelativeTime, normalizePath, tildeCollapse, tildeExpand } from '../panel/helpers'; import { SelectionHistory } from './selectionHistory'; @@ -11,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 { @@ -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; - } - - async moveSelection(delta: number): Promise { - 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 = '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. @@ -250,7 +203,8 @@ export class DoomFindFilePanel { 'doom-workspace.readDirectory', this.makeUri(this.currentDir).toString() ); - } catch { + } catch (err) { + console.warn('[DoomFindFile] readDirectory failed:', err); this.allItems = []; return; } @@ -262,16 +216,16 @@ export class DoomFindFilePanel { 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); @@ -291,7 +245,7 @@ export class DoomFindFilePanel { this.allItems = [...dirs, ...files]; } - private filterItems(): void { + protected filterItems(): void { this.activeIndex = 0; const q = this.filter.toLowerCase(); @@ -303,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); @@ -382,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, @@ -394,8 +326,6 @@ export class DoomFindFilePanel { statusWidthCh: this.getStatusWidthCh(), title: 'Find File', }; - - void this.view.webview.postMessage({ type: 'render', state }); } private getStatusLabel(): string { @@ -409,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/fuzzy.ts b/src/search/fuzzy.ts deleted file mode 100644 index 9398f33..0000000 --- a/src/search/fuzzy.ts +++ /dev/null @@ -1,1080 +0,0 @@ -import * as vscode from 'vscode'; -import { createNonce, fuzzyMatch } from '../panel/helpers'; - -// --------------------------------------------------------------------------- -// Search models -// --------------------------------------------------------------------------- - -interface SearchItem { - fileLabel?: string; - line: number; - lineLabel: string; - searchText: string; - text: string; - uri?: vscode.Uri; -} - -interface SearchMatch { - item: SearchItem; - matches: number[]; - score: number; -} - -interface SearchRenderHeaderItem { - fileLabel: string; - type: 'header'; -} - -interface SearchRenderResultItem { - index: number; - lineLabel: string; - matches: number[]; - text: string; - type: 'result'; -} - -type SearchRenderItem = SearchRenderHeaderItem | SearchRenderResultItem; - -interface SearchState { - activeIndex: number; - emptyText: string; - items: SearchRenderItem[]; - placeholder: string; - promptLabel: string; - query: string; - statusLabel: string; - statusWidthCh: number; - title: string; -} - -interface SearchMessage { - index?: number; - query?: string; - type: 'activate' | 'close' | 'move' | 'query' | 'ready'; -} - -interface SearchOptions { - notifyWhenMissing?: boolean; - resetQuery?: boolean; -} - -type SearchMode = 'editor' | 'workspace'; - -export class DoomFuzzySearchPanel { - static readonly visibleContextKey = 'doom.fuzzySearchVisible'; - - 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; - - /** Switches to editor mode and seeds search state from the active editor. Returns false if no editor is open. */ - prepareShow(): boolean { - this.mode = 'editor'; - return this.initializeFromActiveEditor({ notifyWhenMissing: true, resetQuery: true }); - } - - /** Switches to workspace mode and primes state for a workspace search. Returns false if no folder is open. */ - prepareShowWorkspace(): boolean { - this.mode = 'workspace'; - 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(); - } - - /** 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. */ - async moveSelection(delta: number): Promise { - 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') { - await this.revealEditorLine(this.filteredItems[nextIndex].item.line); - } - this.render(); - } - - /** Confirms the active result: opens the file/line and closes the panel. Sets `accepted` to suppress selection restore. */ - async activateSelection(): Promise { - if (!this.view?.visible || this.filteredItems.length === 0) { - return; - } - - const item = this.filteredItems[this.activeIndex]; - if (!item) { - return; - } - - this.accepted = true; - if (this.mode === 'workspace') { - await this.openWorkspaceItem(item.item); - } else { - await this.revealEditorLine(item.item.line); - } - 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(); - - 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.restoreSelectionIfNeeded(); - }), - webviewView.webview.onDidReceiveMessage((message: SearchMessage) => { - void this.handleMessage(message); - }) - ); - } - - /** 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); - } - - /** Re-initializes and re-renders when the panel becomes visible again — handles both modes. */ - private async refreshVisibleSearch(): Promise { - this.updateViewMetadata(); - if (this.mode === 'workspace') { - if (!this.initializeWorkspaceSearch({ resetQuery: true })) { - return; - } - - this.render(); - await this.loadWorkspaceItems(); - return; - } - - if (!this.initializeFromActiveEditor({ resetQuery: true })) { - return; - } - - this.render(); - } - - /** Stamps mode-appropriate title and description onto the sidebar pane header. */ - private updateViewMetadata(): void { - if (!this.view) { - return; - } - - if (this.mode === 'workspace') { - this.view.title = 'Project Search'; - this.view.description = `Search project ${this.getWorkspaceLabel()}`; - return; - } - - this.view.title = 'Fuzzy Search'; - this.view.description = 'Search current file'; - } - - /** - * Resets all search state and loads lines from the currently active editor. - * Snapshots the starting selection so it can be restored on cancel. - * Returns false (and optionally notifies) when no editor is open. - */ - private initializeFromActiveEditor(options: SearchOptions = {}): boolean { - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - this.accepted = false; - this.activeIndex = 0; - this.currentItems = []; - this.filteredItems = []; - this.startingSelection = undefined; - this.targetEditor = undefined; - this.loading = false; - if (options.resetQuery) { - this.query = ''; - } - if (options.notifyWhenMissing) { - void vscode.window.showInformationMessage('Open a file first to use fuzzy search.'); - } - return false; - } - - this.accepted = false; - this.activeIndex = 0; - this.loading = false; - this.startingSelection = activeEditor.selection; - this.targetEditor = activeEditor; - if (options.resetQuery) { - this.query = ''; - } - this.currentItems = this.buildDocumentItems(activeEditor.document); - this.filterItems(); - return true; - } - - /** - * Resets state for a fresh workspace search. Items are empty until `loadWorkspaceItems` resolves. - * Returns false (and optionally notifies) when no workspace folder exists. - */ - private initializeWorkspaceSearch(options: SearchOptions = {}): boolean { - if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { - this.accepted = false; - this.activeIndex = 0; - this.currentItems = []; - this.filteredItems = []; - this.startingSelection = undefined; - this.targetEditor = undefined; - this.loading = false; - if (options.resetQuery) { - this.query = ''; - } - if (options.notifyWhenMissing) { - void vscode.window.showInformationMessage('Open a folder or workspace first to use project search.'); - } - return false; - } - - this.accepted = false; - this.activeIndex = 0; - this.currentItems = []; - this.filteredItems = []; - this.loading = true; - this.startingSelection = undefined; - this.targetEditor = undefined; - if (options.resetQuery) { - this.query = ''; - } - 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); - } - - private static readonly loadBatchSize = 20; - - /** - * 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) { - this.loading = false; - this.currentItems = this.workspaceCache; - this.filterItems(); - this.render(); - return; - } - - const loadId = ++this.loadSequence; - this.loading = true; - this.render(); - - const files = await vscode.workspace.findFiles('**/*', DoomFuzzySearchPanel.workspaceExcludeGlob); - const items: SearchItem[] = []; - - for (let i = 0; i < files.length; i += DoomFuzzySearchPanel.loadBatchSize) { - if (loadId !== this.loadSequence || this.mode !== 'workspace') { - return; - } - - const batch = files.slice(i, i + DoomFuzzySearchPanel.loadBatchSize); - const results = await Promise.allSettled(batch.map((uri) => this.loadFileItems(uri))); - - for (const result of results) { - if (result.status === 'fulfilled') { - items.push(...result.value); - } - } - } - - if (loadId !== this.loadSequence || this.mode !== 'workspace') { - return; - } - - 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(); - 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 { - return []; - } - if (!stat || stat.size > DoomFuzzySearchPanel.workspaceFileSizeLimit || stat.type !== vscode.FileType.File) { - return []; - } - - try { - const document = await vscode.workspace.openTextDocument(uri); - return this.buildWorkspaceItems(document); - } catch { - return []; - } - } - - /** Same as `buildDocumentItems` but attaches `fileLabel` and `uri` for workspace-mode navigation. */ - private buildWorkspaceItems(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); - } - - /** - * 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. - * Workspace mode: shows nothing until 2+ chars are typed, then groups by file via `groupWorkspaceMatches`. - */ - private filterItems(): void { - this.activeIndex = 0; - const query = this.query.trim().toLowerCase(); - - if (this.loading) { - this.filteredItems = []; - return; - } - - if (query.length < 2) { - if (this.mode === 'workspace') { - this.filteredItems = []; - return; - } - - this.filteredItems = this.currentItems - .slice(0, 200) - .map((item) => ({ - item, - matches: [], - score: 0, - })); - return; - } - - const matches = this.currentItems - .map((item) => { - const match = fuzzyMatch(item.searchText, query); - if (!match) { - return undefined; - } - - return { - item, - matches: match.indices, - score: match.score, - }; - }) - .filter((entry): entry is SearchMatch => entry !== undefined); - - this.filteredItems = this.mode === 'workspace' - ? this.groupWorkspaceMatches(matches).slice(0, 200) - : matches - .sort((left, right) => left.item.line - right.item.line) - .slice(0, 200); - } - - /** Groups matches by file (alphabetically), with lines within each file sorted by line number. */ - private groupWorkspaceMatches(matches: SearchMatch[]): SearchMatch[] { - const groups = new Map(); - - for (const match of matches) { - const fileLabel = match.item.fileLabel ?? ''; - const existing = groups.get(fileLabel); - if (existing) { - existing.matches.push(match); - continue; - } - - groups.set(fileLabel, { - fileLabel, - matches: [match], - }); - } - - return Array.from(groups.values()) - .sort((left, right) => left.fileLabel.localeCompare(right.fileLabel)) - .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) { - await 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') { - await 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 async revealEditorLine(line: number): Promise { - const editor = this.targetEditor; - if (!editor) { - return; - } - - const position = new vscode.Position(line, 0); - const range = new vscode.Range(position, position); - editor.revealRange(range, vscode.TextEditorRevealType.InCenter); - editor.selection = new vscode.Selection(position, position); - } - - /** Opens a workspace file in a non-preview editor tab and jumps to the matched line. */ - private async openWorkspaceItem(item: SearchItem): Promise { - if (!item.uri) { - return; - } - - const document = await vscode.workspace.openTextDocument(item.uri); - const editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - }); - const position = new vscode.Position(item.line, 0); - const range = new vscode.Range(position, position); - editor.revealRange(range, vscode.TextEditorRevealType.InCenter); - 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; - } - - const activeIndex = this.filteredItems.length === 0 - ? 0 - : Math.min(this.activeIndex, this.filteredItems.length - 1); - this.activeIndex = activeIndex; - - const state: SearchState = { - activeIndex, - emptyText: this.getEmptyText(), - items: this.toRenderItems(), - placeholder: this.mode === 'workspace' - ? 'Type to fuzzy search project' - : 'Type to fuzzy search current file', - promptLabel: this.mode === 'workspace' - ? `Search (Project ${this.getWorkspaceLabel()}):` - : 'Go to line:', - query: this.query, - statusLabel: this.getStatusLabel(), - 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. */ - private toRenderItems(): SearchRenderItem[] { - if (this.mode !== 'workspace') { - return this.filteredItems.map((entry, index) => ({ - index, - lineLabel: entry.item.lineLabel, - matches: entry.matches, - text: entry.item.text, - type: 'result', - })); - } - - const items: SearchRenderItem[] = []; - let currentFile = ''; - - this.filteredItems.forEach((entry, index) => { - const fileLabel = entry.item.fileLabel ?? ''; - if (fileLabel !== currentFile) { - currentFile = fileLabel; - items.push({ - fileLabel, - type: 'header', - }); - } - - items.push({ - index, - lineLabel: entry.item.lineLabel, - matches: entry.matches, - text: entry.item.text, - type: 'result', - }); - }); - - return items; - } - - /** Returns the "N/M" status string — workspace uses match count, editor uses absolute line number. */ - private getStatusLabel(): string { - if (this.mode === 'workspace') { - const total = this.filteredItems.length; - if (total === 0) { - return '0/0'; - } - - return `${this.activeIndex + 1}/${total}`; - } - - const totalLines = this.targetEditor?.document.lineCount ?? 0; - const activeItem = this.filteredItems[this.activeIndex]?.item; - return activeItem ? `${activeItem.line + 1}/${totalLines}` : `0/${totalLines}`; - } - - /** Computes a fixed CSS `ch` width for the status column so it never causes layout shift as numbers change. */ - private getStatusWidthCh(): number { - if (this.mode === 'workspace') { - const total = Math.max(this.filteredItems.length, 0); - const digits = Math.max(String(total).length, 1); - return digits * 2 + 1; - } - - const totalLines = Math.max(this.targetEditor?.document.lineCount ?? 0, 0); - const digits = Math.max(String(totalLines).length, 1); - return digits * 2 + 1; - } - - /** Returns the appropriate empty-state message depending on load state, mode, and query length. */ - private getEmptyText(): string { - if (this.loading) { - return 'Loading project files...'; - } - - if (this.mode === 'workspace' && this.query.trim().length === 0) { - return 'Type to fuzzy search project.'; - } - - return 'No matches.'; - } - - /** Returns the workspace name for UI labels, falling back to 'workspace' if unnamed. */ - private getWorkspaceLabel(): string { - return vscode.workspace.name ?? 'workspace'; - } - - /** Scrolls the editor back to the pre-search cursor position when the user dismisses without confirming. */ - private restoreSelectionIfNeeded(): void { - if (this.mode !== 'editor' || this.accepted || !this.startingSelection || !this.targetEditor) { - return; - } - - const range = new vscode.Range(this.startingSelection.start, this.startingSelection.end); - this.targetEditor.revealRange(range, vscode.TextEditorRevealType.InCenter); - 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/search/projectFile.ts b/src/search/projectFile.ts index a140bab..6605af7 100644 --- a/src/search/projectFile.ts +++ b/src/search/projectFile.ts @@ -1,6 +1,10 @@ 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; // --------------------------------------------------------------------------- // Project file models @@ -44,12 +48,6 @@ interface ProjectFileState { title: string; } -interface ProjectFileMessage { - index?: number; - query?: string; - type: 'activate' | 'close' | 'move' | 'query' | 'ready'; -} - // --------------------------------------------------------------------------- // File listing // --------------------------------------------------------------------------- @@ -86,22 +84,25 @@ 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, + private readonly fileIndex: WorkspaceFileIndex, + ) { + super(); + // Drop the cached file list whenever the workspace tree changes. + this.fileIndex.onCacheInvalidated(() => { this.workspaceCache = undefined; }); + } - 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; /** Resets query/index and validates a workspace exists. Returns false if not. */ prepareShow(): boolean { @@ -119,40 +120,41 @@ export class DoomProjectFilePanel { await this.loadProjectItems(); } - /** 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. */ - async moveSelection(delta: number): Promise { - 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()}`; + } - if (nextIndex === this.activeIndex) { + /** Seeds open-tab ordering before the first render once items are loaded. */ + protected onReady(): void { + if (!this.loading) { + this.seedItems(); + } + } + + /** 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; } @@ -189,47 +191,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. @@ -279,15 +240,6 @@ export class DoomProjectFilePanel { } 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(); @@ -348,12 +300,12 @@ 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(); 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,55 +330,11 @@ 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. */ - 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); @@ -444,7 +352,7 @@ export class DoomProjectFilePanel { type: 'result', })); - const state: ProjectFileState = { + return { activeIndex, emptyText: this.loading ? 'Loading project files...' : 'No matches.', items, @@ -455,8 +363,6 @@ export class DoomProjectFilePanel { statusWidthCh: this.getStatusWidthCh(), title: 'Find File in Project', }; - - void this.view.webview.postMessage({ type: 'render', state }); } private getStatusLabel(): string { @@ -474,13 +380,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 d2c7ef3..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) // --------------------------------------------------------------------------- @@ -113,7 +108,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 []; } @@ -172,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; @@ -184,22 +181,17 @@ 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. Always returns true — no precondition needed. + * Resets state. * Pass `onProjectSelected` to intercept selection instead of opening the folder. */ - 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. */ @@ -216,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. */ - async moveSelection(delta: number): Promise { - 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; } @@ -268,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(); @@ -342,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); @@ -403,7 +301,7 @@ export class DoomRecentProjectsPanel { type: 'result', })); - const state: RecentProjectState = { + return { activeIndex, emptyText: this.loading ? 'Loading recent projects...' : 'No recent projects found.', items, @@ -414,8 +312,6 @@ export class DoomRecentProjectsPanel { statusWidthCh: this.getStatusWidthCh(), title: 'Open Recent Project', }; - - void this.view.webview.postMessage({ type: 'render', state }); } private getStatusLabel(): string { @@ -429,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 new file mode 100644 index 0000000..1663184 --- /dev/null +++ b/src/search/search.ts @@ -0,0 +1,698 @@ +import * as vscode from 'vscode'; +import { DoomWebviewController } from '../panel/controller'; +import { createNonce, createPanelHtml, substringMatch } from '../panel/helpers'; + +interface WorkspaceTextSearchResult { + rel: string; + line: number; + text: string; +} + +const MAX_RESULTS = 200; + +// --------------------------------------------------------------------------- +// Search models +// --------------------------------------------------------------------------- + +interface SearchItem { + fileLabel?: string; + line: number; + lineLabel: string; + searchText: string; + text: string; + uri?: vscode.Uri; +} + +interface SearchMatch { + item: SearchItem; + matches: number[]; + score: number; +} + +interface SearchRenderHeaderItem { + fileLabel: string; + type: 'header'; +} + +interface SearchRenderResultItem { + index: number; + lineLabel: string; + matches: number[]; + text: string; + type: 'result'; +} + +type SearchRenderItem = SearchRenderHeaderItem | SearchRenderResultItem; + +interface SearchState { + activeIndex: number; + emptyText: string; + items: SearchRenderItem[]; + placeholder: string; + promptLabel: string; + query: string; + statusLabel: string; + statusWidthCh: number; + title: string; +} + +interface SearchOptions { + notifyWhenMissing?: boolean; + resetQuery?: boolean; +} + +type SearchMode = 'editor' | 'workspace'; + +/** 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 accepted = false; + private currentItems: SearchItem[] = []; + private filteredItems: SearchMatch[] = []; + 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; + + constructor() { + super(); + } + + /** Switches to editor mode and seeds search state from the active editor. Returns false if no editor is open. */ + prepareShow(): boolean { + this.mode = 'editor'; + return this.initializeFromActiveEditor({ notifyWhenMissing: true, resetQuery: true }); + } + + /** Switches to workspace mode and primes state for a workspace search. Returns false if no folder is open. */ + prepareShowWorkspace(): boolean { + this.mode = 'workspace'; + return this.initializeWorkspaceSearch({ notifyWhenMissing: true, resetQuery: true }); + } + + 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. */ + protected async activateSelection(): Promise { + if (!this.view?.visible || this.filteredItems.length === 0) { + return; + } + + const item = this.filteredItems[this.activeIndex]; + if (!item) { + return; + } + + this.accepted = true; + if (this.mode === 'workspace') { + await this.openWorkspaceItem(item.item); + } else { + this.revealEditorLine(item.item.line); + } + await this.close(); + } + + /** 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.revealEditorLine(this.filteredItems[this.activeIndex].item.line); + } + + /** Restores the pre-search selection when the panel is detached. */ + protected onDetach(): void { + this.restoreSelectionIfNeeded(); + } + + /** 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. */ + private refreshVisibleSearch(): void { + this.updateViewMetadata(); + if (this.mode === 'workspace') { + if (!this.initializeWorkspaceSearch({ resetQuery: true })) { + return; + } + + this.render(); + return; + } + + if (!this.initializeFromActiveEditor({ resetQuery: true })) { + return; + } + + this.render(); + } + + /** Stamps mode-appropriate title and description onto the sidebar pane header. */ + protected updateViewMetadata(): void { + if (!this.view) { + return; + } + + if (this.mode === 'workspace') { + this.view.title = 'Project Search'; + this.view.description = `Search project ${this.getWorkspaceLabel()}`; + return; + } + + this.view.title = 'Fuzzy Search'; + this.view.description = 'Search current file'; + } + + /** + * Resets all search state and loads lines from the currently active editor. + * Snapshots the starting selection so it can be restored on cancel. + * Returns false (and optionally notifies) when no editor is open. + */ + private initializeFromActiveEditor(options: SearchOptions = {}): boolean { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + this.accepted = false; + this.activeIndex = 0; + this.currentItems = []; + this.filteredItems = []; + this.startingSelection = undefined; + this.targetEditor = undefined; + this.loading = false; + if (options.resetQuery) { + this.query = ''; + } + if (options.notifyWhenMissing) { + void vscode.window.showInformationMessage('Open a file first to use search.'); + } + return false; + } + + this.accepted = false; + this.activeIndex = 0; + this.loading = false; + this.startingSelection = activeEditor.selection; + this.targetEditor = activeEditor; + if (options.resetQuery) { + this.query = ''; + } + this.currentItems = this.buildDocumentItems(activeEditor.document); + this.filterItems(); + return true; + } + + /** + * 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; + this.currentItems = []; + this.filteredItems = []; + this.startingSelection = undefined; + this.targetEditor = undefined; + this.loading = false; + if (options.resetQuery) { + this.query = ''; + } + if (options.notifyWhenMissing) { + void vscode.window.showInformationMessage('Open a folder or workspace first to use project search.'); + } + return false; + } + + this.accepted = false; + this.activeIndex = 0; + this.currentItems = []; + this.filteredItems = []; + this.loading = false; + this.startingSelection = undefined; + this.targetEditor = undefined; + if (options.resetQuery) { + this.query = ''; + } + return true; + } + + /** 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; + } + + this.searchCanceller?.cancel(); + this.searchCanceller = undefined; + if (this.searchDebounceTimer !== undefined) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = undefined; + } + + if (query.trim().length < 2) { + this.loading = false; + this.currentItems = []; + this.filterItems(); + this.render(); + return; + } + + this.loading = true; + this.currentItems = []; + this.render(); + + this.searchDebounceTimer = setTimeout(() => { + this.searchDebounceTimer = undefined; + void this.runWorkspaceSearch(query); + }, 200); + } + + /** 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 (canceller.token.isCancellationRequested) { + canceller.dispose(); + return; + } + + canceller.dispose(); + this.searchCanceller = undefined; + this.resultsCapped = rawResults.length >= MAX_RESULTS; + this.loading = false; + 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(); + } + + /** 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); + } + + /** + * 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`. + */ + protected filterItems(): void { + this.activeIndex = 0; + const query = this.query.trim().toLowerCase(); + + if (this.loading) { + this.filteredItems = []; + return; + } + + if (query.length < 2) { + if (this.mode === 'workspace') { + this.filteredItems = []; + return; + } + + this.filteredItems = this.currentItems + .slice(0, MAX_RESULTS) + .map((item) => ({ + item, + matches: [], + score: 0, + })); + return; + } + + const matches = this.currentItems + .map((item) => { + const match = substringMatch(item.searchText, query); + if (!match) { + return undefined; + } + + return { + item, + matches: match.indices, + score: match.score, + }; + }) + .filter((entry): entry is SearchMatch => entry !== undefined); + + this.filteredItems = this.mode === 'workspace' + ? this.groupWorkspaceMatches(matches).slice(0, MAX_RESULTS) + : matches + .sort((left, right) => left.item.line - right.item.line) + .slice(0, MAX_RESULTS); + } + + /** Groups matches by file (alphabetically), with lines within each file sorted by line number. */ + private groupWorkspaceMatches(matches: SearchMatch[]): SearchMatch[] { + const groups = new Map(); + + for (const match of matches) { + const fileLabel = match.item.fileLabel ?? ''; + const existing = groups.get(fileLabel); + if (existing) { + existing.matches.push(match); + continue; + } + + groups.set(fileLabel, { + fileLabel, + matches: [match], + }); + } + + return Array.from(groups.values()) + .sort((left, right) => left.fileLabel.localeCompare(right.fileLabel)) + .flatMap((group) => group.matches.sort((left, right) => left.item.line - right.item.line)); + } + + /** 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; + if (!editor) { + return; + } + + const position = new vscode.Position(line, 0); + const range = new vscode.Range(position, position); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + editor.selection = new vscode.Selection(position, position); + } + + /** Opens a workspace file in a non-preview editor tab and jumps to the matched line. */ + private async openWorkspaceItem(item: SearchItem): Promise { + if (!item.uri) { + return; + } + + const document = await vscode.workspace.openTextDocument(item.uri); + const editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }); + const position = new vscode.Position(item.line, 0); + const range = new vscode.Range(position, position); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + editor.selection = new vscode.Selection(position, position); + } + + /** 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; + + return { + activeIndex, + emptyText: this.getEmptyText(), + items: this.toRenderItems(), + placeholder: this.mode === 'workspace' + ? 'Type to search project' + : 'Type to search current file', + promptLabel: this.mode === 'workspace' + ? `Search (Project ${this.getWorkspaceLabel()}):` + : 'Go to line:', + query: this.query, + statusLabel: this.getStatusLabel(), + statusWidthCh: this.getStatusWidthCh(), + title: this.mode === 'workspace' ? 'Project Search' : 'Fuzzy Search', + }; + } + + /** Converts filtered matches to the render model. In workspace mode inserts file header rows on group boundaries. */ + private toRenderItems(): SearchRenderItem[] { + if (this.mode !== 'workspace') { + return this.filteredItems.map((entry, index) => ({ + index, + lineLabel: entry.item.lineLabel, + matches: entry.matches, + text: entry.item.text, + type: 'result', + })); + } + + const items: SearchRenderItem[] = []; + let currentFile = ''; + + this.filteredItems.forEach((entry, index) => { + const fileLabel = entry.item.fileLabel ?? ''; + if (fileLabel !== currentFile) { + currentFile = fileLabel; + items.push({ + fileLabel, + type: 'header', + }); + } + + items.push({ + index, + lineLabel: entry.item.lineLabel, + matches: entry.matches, + text: entry.item.text, + type: 'result', + }); + }); + + return items; + } + + /** Returns the "N/M" status string — workspace uses match count, editor uses absolute line number. */ + private getStatusLabel(): string { + if (this.mode === 'workspace') { + const total = this.filteredItems.length; + if (total === 0) { + return '0/0'; + } + + return `${this.activeIndex + 1}/${total}`; + } + + const totalLines = this.targetEditor?.document.lineCount ?? 0; + const activeItem = this.filteredItems[this.activeIndex]?.item; + return activeItem ? `${activeItem.line + 1}/${totalLines}` : `0/${totalLines}`; + } + + /** Computes a fixed CSS `ch` width for the status column so it never causes layout shift as numbers change. */ + private getStatusWidthCh(): number { + if (this.mode === 'workspace') { + const total = Math.max(this.filteredItems.length, 0); + const digits = Math.max(String(total).length, 1); + return digits * 2 + 1; + } + + const totalLines = Math.max(this.targetEditor?.document.lineCount ?? 0, 0); + const digits = Math.max(String(totalLines).length, 1); + return digits * 2 + 1; + } + + /** Returns the appropriate empty-state message depending on load state, mode, and query length. */ + private getEmptyText(): string { + if (this.loading) { + return 'Loading project files...'; + } + + if (this.mode === 'workspace' && this.query.trim().length === 0) { + return 'Type to search project.'; + } + + return 'No matches.'; + } + + /** Returns the workspace name for UI labels, falling back to 'workspace' if unnamed. */ + private getWorkspaceLabel(): string { + return vscode.workspace.name ?? 'workspace'; + } + + /** Scrolls the editor back to the pre-search cursor position when the user dismisses without confirming. */ + private restoreSelectionIfNeeded(): void { + if (this.mode !== 'editor' || this.accepted || !this.startingSelection || !this.targetEditor) { + return; + } + + const range = new vscode.Range(this.startingSelection.start, this.startingSelection.end); + this.targetEditor.revealRange(range, vscode.TextEditorRevealType.InCenter); + this.targetEditor.selection = this.startingSelection; + } + + protected getHtml(webview: vscode.Webview): string { + return createPanelHtml({ + cspSource: webview.cspSource, + nonce: createNonce(), + title: 'Fuzzy Search', + layoutCss: SEARCH_LAYOUT_CSS, + renderItem: SEARCH_RENDER_ITEM, + }); + } +} 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; + } +} 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 51eda3b..8e6b7d6 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'; @@ -16,6 +15,7 @@ import { import { applyDefaultsToConfiguration, hasUserOwnedSettingValue, runInstallFlow } from '../onboarding/install'; import { DOOM_MANAGED_VIM_BINDING_SETTINGS, + DOOM_STALE_VIM_BINDING_SETTINGS, getDoomManagedVimBindingConflictKey, hasEquivalentDoomManagedVimBinding, isDoomManagedVimBindingSetting, @@ -28,6 +28,7 @@ import { resolveWindowLeftTarget, resolveWindowRightTarget, } from '../window/mru'; +import { resolveWindowDeleteAction } from '../window/windowCommands'; import { applyTrackedUiContextCommand, evaluateWhenExpression, @@ -36,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); @@ -72,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`); } }); @@ -82,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, @@ -125,6 +109,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( 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/bindingsPanel.ts b/src/whichkey/bindingsPanel.ts index 703809e..b2afe49 100644 --- a/src/whichkey/bindingsPanel.ts +++ b/src/whichkey/bindingsPanel.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; -import { createNonce, fuzzyMatch } 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) => { @@ -146,12 +164,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, @@ -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 diff --git a/src/whichkey/menu.ts b/src/whichkey/menu.ts index 06b55eb..f71bc75 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 @@ -146,10 +147,8 @@ export function applyTrackedUiContextCommand( } } -/** 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'; -} +const BLUR_ENABLE_DELAY_MS = 200; +const SUPPRESS_WINDOW_MS = 150; /** Maps literal chars to symbolic names matching the webview keydown handler. */ function normalizeBindingKey(value: string): string { @@ -389,19 +388,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 []; } @@ -423,6 +428,8 @@ function getWhichKeyTriggerBindings(): WhichKeyTriggerBinding[] { when: entry.when, }]; }); + + return cachedTriggerBindings; } /** @@ -488,7 +495,7 @@ function resolveConditionalBinding(state: DoomWhichKeyMenu, binding: WhichKeyBin const triggeredCondition = selectTriggeredConditionForKey( binding.key, contextValues, - getWhichKeyTriggerBindings(), + getWhichKeyTriggerBindings(state.extensionPackageJson), ); if (triggeredCondition) { @@ -552,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 Math.random().toString(36).slice(2, 12); -} // --------------------------------------------------------------------------- // Which-key panel controller @@ -565,7 +568,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, @@ -586,6 +591,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; } @@ -863,7 +876,7 @@ export class DoomWhichKeyMenu { return; } -if (message.type !== 'activate' || message.index === undefined) { + if (message.type !== 'activate' || message.index === undefined) { return; } @@ -980,6 +993,13 @@ if (message.type !== 'activate' || message.index === undefined) { 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) { @@ -1009,7 +1029,7 @@ if (message.type !== 'activate' || message.index === undefined) { * 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'`, @@ -1144,7 +1164,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 +1252,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 +1290,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 +1313,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); } }; 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, + ); +}