From a965d6b78cbcd18e8353f6021b7fde7f0824b0c6 Mon Sep 17 00:00:00 2001 From: Jason Dent Date: Sun, 29 Oct 2023 06:31:15 +0100 Subject: [PATCH] dev: work on issues panel (#2905) --- docs/_includes/generated-docs/commands.md | 77 +++--- package.json | 32 +-- packages/_server/src/api/CommandsToClient.ts | 2 +- packages/_server/src/api/api.ts | 2 +- packages/_server/src/api/apiModels.ts | 23 +- .../_server/src/api/{types.ts => generics.ts} | 0 packages/_server/src/codeAction.test.mts | 4 +- packages/_server/src/codeActions.mts | 2 +- .../_server/src/config/documentSettings.mts | 85 +++--- packages/_server/src/server.mts | 42 +-- packages/_server/src/suggestionsServer.mts | 67 +++++ packages/client/src/applyCorrections.ts | 159 +++++++++++ packages/client/src/client/client.ts | 7 +- packages/client/src/commands.ts | 140 +--------- packages/client/src/extension.ts | 2 +- packages/client/src/issueViewer/viewer.ts | 261 ++++++++++++++++-- packages/client/src/util/findEditor.ts | 18 +- 17 files changed, 641 insertions(+), 282 deletions(-) rename packages/_server/src/api/{types.ts => generics.ts} (100%) create mode 100644 packages/_server/src/suggestionsServer.mts create mode 100644 packages/client/src/applyCorrections.ts diff --git a/docs/_includes/generated-docs/commands.md b/docs/_includes/generated-docs/commands.md index 04b80b8a03..f4e2c6659f 100644 --- a/docs/_includes/generated-docs/commands.md +++ b/docs/_includes/generated-docs/commands.md @@ -2,41 +2,42 @@ # Commands -| Command | Title | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `cSpell.addIgnoreWord` | Ignore Words | -| `cSpell.addIgnoreWordsToFolder` | Ignore Word in Folder Settings | -| `cSpell.addIgnoreWordsToUser` | Ignore Words in User Settings | -| `cSpell.addIgnoreWordsToWorkspace` | Ignore Words in Workspace Settings | -| `cSpell.addIssuesToDictionary` | Add All Spelling Issues to Dictionary | -| `cSpell.addWordToCSpellConfig` | Add Words to CSpell Configuration | -| `cSpell.addWordToDictionary` | Add Words to Dictionary | -| `cSpell.addWordToFolderDictionary` | Add Words to Folder Dictionary | -| `cSpell.addWordToFolderSettings` | Add Words to Folder Settings | -| `cSpell.addWordToUserDictionary` | Add Words to User Dictionary | -| `cSpell.addWordToUserSettings` | Add Words to User Settings | -| `cSpell.addWordToWorkspaceDictionary` | Add Words to Workspace Dictionary | -| `cSpell.addWordToWorkspaceSettings` | Add Words to Workspace Settings | -| `cSpell.autoFixSpellingIssues` | Fix all issues with a preferred suggestion in the current document. | -| `cSpell.createCSpellConfig` | Create a CSpell Configuration File. | -| `cSpell.createCustomDictionary` | Create a Custom Dictionary File. | -| `cSpell.disableCurrentLanguage` | Disable Spell Checking Document Language | -| `cSpell.disableForGlobal` | Disable Spell Checking by Default | -| `cSpell.disableForWorkspace` | Disable Spell Checking For Workspace | -| `cSpell.displayCSpellInfo` | Show Spell Checker Configuration Info | -| `cSpell.enableCurrentLanguage` | Enable Spell Checking Document Language | -| `cSpell.enableForGlobal` | Enable Spell Checking by Default | -| `cSpell.enableForWorkspace` | Enable Spell Checking For Workspace | -| `cSpell.goToNextSpellingIssue` | Go to Next Spelling Issue | -| `cSpell.goToNextSpellingIssueAndSuggest` | Go to Next Spelling Issue and Suggest | -| `cSpell.goToPreviousSpellingIssue` | Go to Previous Spelling Issue | -| `cSpell.goToPreviousSpellingIssueAndSuggest` | Go to Previous Spelling Issue and Suggest | -| `cSpell.logPerfTimeline` | Log CSpell performance times to console | -| `cSpell.openSuggestionsForIssue` | Show Suggestions
**When:**
`view == cspell-info.issuesView` | -| `cSpell.removeWordFromFolderDictionary` | Remove Words from the Folder Dictionary | -| `cSpell.removeWordFromUserDictionary` | Remove Words from the Global Dictionary | -| `cSpell.removeWordFromWorkspaceDictionary` | Remove Words from the Workspace Dictionaries | -| `cSpell.suggestSpellingCorrections` | Spelling Suggestions...
**When:**
`editorTextFocus && cSpell.editorMenuContext.showSuggestions` | -| `cSpell.toggleEnableForGlobal` | Toggle Spell Checking in User Settings | -| `cSpell.toggleEnableForWorkspace` | Toggle Spell Checking for Workspace | -| `cSpell.toggleEnableSpellChecker` | Toggle Spell Checking | +| Command | Title | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `cSpell.addIgnoreWord` | Ignore Words | +| `cSpell.addIgnoreWordsToFolder` | Ignore Word in Folder Settings | +| `cSpell.addIgnoreWordsToUser` | Ignore Words in User Settings | +| `cSpell.addIgnoreWordsToWorkspace` | Ignore Words in Workspace Settings | +| `cSpell.addIssuesToDictionary` | Add All Spelling Issues to Dictionary | +| `cSpell.addWordToCSpellConfig` | Add Words to CSpell Configuration | +| `cSpell.addWordToDictionary` | Add Words to Dictionary | +| `cSpell.addWordToFolderDictionary` | Add Words to Folder Dictionary | +| `cSpell.addWordToFolderSettings` | Add Words to Folder Settings | +| `cSpell.addWordToUserDictionary` | Add Words to User Dictionary | +| `cSpell.addWordToUserSettings` | Add Words to User Settings | +| `cSpell.addWordToWorkspaceDictionary` | Add Words to Workspace Dictionary | +| `cSpell.addWordToWorkspaceSettings` | Add Words to Workspace Settings | +| `cSpell.autoFixSpellingIssues` | Fix all issues with a preferred suggestion in the current document. | +| `cSpell.createCSpellConfig` | Create a CSpell Configuration File. | +| `cSpell.createCustomDictionary` | Create a Custom Dictionary File. | +| `cSpell.disableCurrentLanguage` | Disable Spell Checking Document Language | +| `cSpell.disableForGlobal` | Disable Spell Checking by Default | +| `cSpell.disableForWorkspace` | Disable Spell Checking For Workspace | +| `cSpell.displayCSpellInfo` | Show Spell Checker Configuration Info | +| `cSpell.enableCurrentLanguage` | Enable Spell Checking Document Language | +| `cSpell.enableForGlobal` | Enable Spell Checking by Default | +| `cSpell.enableForWorkspace` | Enable Spell Checking For Workspace | +| `cSpell.goToNextSpellingIssue` | Go to Next Spelling Issue | +| `cSpell.goToNextSpellingIssueAndSuggest` | Go to Next Spelling Issue and Suggest | +| `cSpell.goToPreviousSpellingIssue` | Go to Previous Spelling Issue | +| `cSpell.goToPreviousSpellingIssueAndSuggest` | Go to Previous Spelling Issue and Suggest | +| `cSpell.issueViewer.item.autoFixSpellingIssues` | Fix issue with preferred suggestion in the current document.
**When:**
`view == cspell-info.issuesView` | +| `cSpell.issueViewer.item.openSuggestionsForIssue` | Show Suggestions
**When:**
`view == cspell-info.issuesView` | +| `cSpell.logPerfTimeline` | Log CSpell performance times to console | +| `cSpell.removeWordFromFolderDictionary` | Remove Words from the Folder Dictionary | +| `cSpell.removeWordFromUserDictionary` | Remove Words from the Global Dictionary | +| `cSpell.removeWordFromWorkspaceDictionary` | Remove Words from the Workspace Dictionaries | +| `cSpell.suggestSpellingCorrections` | Spelling Suggestions...
**When:**
`editorTextFocus && cSpell.editorMenuContext.showSuggestions` | +| `cSpell.toggleEnableForGlobal` | Toggle Spell Checking in User Settings | +| `cSpell.toggleEnableForWorkspace` | Toggle Spell Checking for Workspace | +| `cSpell.toggleEnableSpellChecker` | Toggle Spell Checking | diff --git a/package.json b/package.json index a97816cfe6..94709fb7e8 100644 --- a/package.json +++ b/package.json @@ -164,8 +164,8 @@ "group": "inline" }, { - "command": "cSpell.openSuggestionsForIssue", - "when": "view == cspell-info.issuesView", + "command": "cSpell.issueViewer.item.autoFixSpellingIssues", + "when": "view == cspell-info.issuesView && viewItem == issue.hasPreferred", "group": "inline" } ] @@ -185,14 +185,7 @@ { "id": "cspell-info-explorer", "title": "Spell Checker", - "icon": "resources/dark/check_circle.svg", - "when": "config.cSpell.experimental.enableSettingsViewerV2" - }, - { - "id": "cspell-regexp-explorer", - "title": "Spell Checker Regular Expression Viewer", - "icon": "resources/dark/check_circle.svg", - "when": "config.cSpell.experimental.enableRegexpView" + "icon": "resources/dark/check_circle.svg" } ] }, @@ -206,15 +199,15 @@ { "type": "webview", "id": "cspell-info.infoView", - "name": "Spell Checker" + "name": "Spell Checker", + "when": "config.cSpell.experimental.enableSettingsViewerV2" }, { "type": "webview", "id": "cspell-info.todoView", - "name": "Spell Checker Todos" - } - ], - "cspell-regexp-explorer": [ + "name": "Spell Checker Todos", + "when": "config.cSpell.experimental.enableSettingsViewerV2" + }, { "id": "cSpellRegExpView", "name": "Regular Expressions", @@ -222,6 +215,7 @@ } ] }, + "viewsWelcome": [], "commands": [ { "command": "cspell-info.showHelloWorld", @@ -410,7 +404,7 @@ "icon": "$(edit)" }, { - "command": "cSpell.openSuggestionsForIssue", + "command": "cSpell.issueViewer.item.openSuggestionsForIssue", "title": "Show Suggestions", "icon": "$(list-unordered)", "enablement": "view == cspell-info.issuesView" @@ -419,6 +413,12 @@ "command": "cSpell.autoFixSpellingIssues", "title": "Fix all issues with a preferred suggestion in the current document.", "icon": "$(lightbulb-autofix)" + }, + { + "command": "cSpell.issueViewer.item.autoFixSpellingIssues", + "title": "Fix issue with preferred suggestion in the current document.", + "icon": "$(lightbulb-autofix)", + "enablement": "view == cspell-info.issuesView" } ], "languages": [ diff --git a/packages/_server/src/api/CommandsToClient.ts b/packages/_server/src/api/CommandsToClient.ts index 1c267204ca..75270a9128 100644 --- a/packages/_server/src/api/CommandsToClient.ts +++ b/packages/_server/src/api/CommandsToClient.ts @@ -1,5 +1,5 @@ import type { ConfigurationTarget } from './apiModels.js'; -import type { OrPromise } from './types.js'; +import type { OrPromise } from './generics.js'; export interface CommandsToClient { addWordsToVSCodeSettingsFromServer: (words: string[], documentUri: string, target: ConfigurationTarget) => void; diff --git a/packages/_server/src/api/api.ts b/packages/_server/src/api/api.ts index 0bde3e4c60..0efd2cfc0f 100644 --- a/packages/_server/src/api/api.ts +++ b/packages/_server/src/api/api.ts @@ -32,7 +32,7 @@ export interface ServerRequestsAPI { getConfigurationForDocument(req: GetConfigurationForDocumentRequest): GetConfigurationForDocumentResult; isSpellCheckEnabled(req: TextDocumentInfo): IsSpellCheckEnabledResult; splitTextIntoWords(req: string): SplitTextIntoWordsResult; - spellingSuggestions(word: string, doc: TextDocumentInfo): SpellingSuggestionsResult; + spellingSuggestions(word: string, doc?: TextDocumentInfo): SpellingSuggestionsResult; } /** Notifications that can be sent to the server */ diff --git a/packages/_server/src/api/apiModels.ts b/packages/_server/src/api/apiModels.ts index 9cabb0ab17..d8f8fbb321 100644 --- a/packages/_server/src/api/apiModels.ts +++ b/packages/_server/src/api/apiModels.ts @@ -12,6 +12,7 @@ export type { ConfigTargetDictionary, ConfigTargetVSCode, } from '../config/configTargets.mjs'; +export type { Position, Range } from 'vscode-languageserver-types'; export interface BlockedFileReason { code: string; @@ -65,13 +66,7 @@ export interface SpellingSuggestionsResult { suggestions: Suggestion[]; } -export interface TextDocumentInfo { - uri?: UriString; - languageId?: string; - text?: string; -} - -export interface GetConfigurationForDocumentRequest extends TextDocumentInfo { +export interface GetConfigurationForDocumentRequest extends Partial { /** used to calculate configTargets, configTargets will be empty if undefined. */ workspaceConfig?: WorkspaceConfigForDocument; } @@ -84,7 +79,17 @@ export interface GetConfigurationForDocumentResult extends IsSpellCheckEnabledRe } export interface TextDocumentRef { - uri: UriString; + readonly uri: DocumentUri; +} + +export interface TextDocumentInfo extends TextDocumentRef { + readonly languageId?: string; + readonly text?: string; + readonly version?: number; +} + +export interface TextDocumentInfoWithText extends TextDocumentInfo { + readonly text: string; } export interface NamedPattern { @@ -93,7 +98,7 @@ export interface NamedPattern { } export interface MatchPatternsToDocumentRequest extends TextDocumentRef { - patterns: (string | NamedPattern)[]; + readonly patterns: (string | NamedPattern)[]; } export interface RegExpMatch { diff --git a/packages/_server/src/api/types.ts b/packages/_server/src/api/generics.ts similarity index 100% rename from packages/_server/src/api/types.ts rename to packages/_server/src/api/generics.ts diff --git a/packages/_server/src/codeAction.test.mts b/packages/_server/src/codeAction.test.mts index 1f97859e43..939ff215b2 100644 --- a/packages/_server/src/codeAction.test.mts +++ b/packages/_server/src/codeAction.test.mts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { onCodeActionHandler } from './codeActions.mjs'; +import { createOnCodeActionHandler } from './codeActions.mjs'; describe('Validate CodeAction', () => { test('onCodeActionHandler', () => { // Place holder test. - expect(typeof onCodeActionHandler).toBe('function'); + expect(typeof createOnCodeActionHandler).toBe('function'); }); }); diff --git a/packages/_server/src/codeActions.mts b/packages/_server/src/codeActions.mts index 0173dc603e..5fbee18258 100644 --- a/packages/_server/src/codeActions.mts +++ b/packages/_server/src/codeActions.mts @@ -42,7 +42,7 @@ export interface CodeActionHandlerDependencies { fetchWorkspaceConfigForDocument: (uri: UriString) => Promise; } -export function onCodeActionHandler( +export function createOnCodeActionHandler( documents: TextDocuments, dependencies: CodeActionHandlerDependencies, ): (params: CodeActionParams) => Promise { diff --git a/packages/_server/src/config/documentSettings.mts b/packages/_server/src/config/documentSettings.mts index 1ba6bbd3fe..fe3008d9d6 100644 --- a/packages/_server/src/config/documentSettings.mts +++ b/packages/_server/src/config/documentSettings.mts @@ -105,15 +105,13 @@ const _defaultSettings: CSpellUserSettings = Object.freeze(Object.create(null)); const defaultCheckOnlyEnabledFileTypes = true; -interface Clearable { - clear: () => void; -} +type ClearFn = () => void; export class DocumentSettings { // Cache per folder settings - private cachedValues: Clearable[] = []; - private readonly fetchSettingsForUri = this.createCache((docUri: string) => this._fetchSettingsForUri(docUri)); - private readonly fetchVSCodeConfiguration = this.createCache((uri: string) => this._fetchVSCodeConfiguration(uri)); + private valuesToClearOnReset: ClearFn[] = []; + private readonly fetchSettingsForUri = this.createCache((docUri: string | undefined) => this._fetchSettingsForUri(docUri)); + private readonly fetchVSCodeConfiguration = this.createCache((uri?: string) => this._fetchVSCodeConfiguration(uri)); private readonly fetchRepoRootForDir = this.createCache((dir: FsPath) => findRepoRoot(dir)); public readonly fetchWorkspaceConfiguration = this.createCache((docUri: DocumentUri) => this._fetchWorkspaceConfiguration(docUri)); private readonly _folders = this.createLazy(() => this.fetchFolders()); @@ -132,7 +130,7 @@ export class DocumentSettings { return this.getUriSettings(document.uri); } - getUriSettings(uri: string): Promise { + getUriSettings(uri: string | undefined): Promise { return this.fetchUriSettings(uri); } @@ -203,7 +201,7 @@ export class DocumentSettings { async resetSettings(): Promise { log('resetSettings'); const waitFor = clearCachedFiles(); - this.cachedValues.forEach((cache) => cache.clear()); + this.valuesToClearOnReset.forEach((fn) => fn()); this._version += 1; this.gitIgnore = new GitIgnore(); await waitFor; @@ -234,35 +232,48 @@ export class DocumentSettings { await this.resetSettings(); } - private async fetchUriSettings(uri: string): Promise { + private async fetchUriSettings(uri: string | undefined): Promise { const exSettings = await this.fetchUriSettingsEx(uri); return exSettings.settings; } - private fetchUriSettingsEx(uri: string): Promise { + private fetchUriSettingsEx(uri: string | undefined): Promise { return this.fetchSettingsForUri(uri); } - private async findMatchingFolder(docUri: string, defaultTo: WorkspaceFolder): Promise; - private async findMatchingFolder(docUri: string, defaultTo?: WorkspaceFolder | undefined): Promise; - private async findMatchingFolder(docUri: string, defaultTo: WorkspaceFolder | undefined): Promise { - return (await this.matchingFoldersForUri(docUri))[0] || defaultTo; + private async findMatchingFolder(docUri: string | undefined, defaultTo: WorkspaceFolder): Promise; + private async findMatchingFolder( + docUri: string | undefined, + defaultTo?: WorkspaceFolder | undefined, + ): Promise; + private async findMatchingFolder( + docUri: string | undefined, + defaultTo: WorkspaceFolder | undefined, + ): Promise { + return (docUri && (await this.matchingFoldersForUri(docUri))[0]) || defaultTo; } - private rootForUri(docUri: string | undefined) { - return Uri.parse(docUri || defaultRootUri).with({ path: '' }); + /** + * Calculate the schema and domain for the uri; + * @param docUri + * @returns + */ + private rootSchemaAndDomainForUri(docUri: string | Uri | undefined) { + const uri = Uri.isUri(docUri) ? docUri : Uri.parse(docUri || defaultRootUri); + return uri.with({ path: '', query: null, fragment: null }); } - private rootFolderForUri(docUri: string | undefined) { - const root = this.rootForUri(docUri); + private rootSchemaAndDomainFolderForUri(docUri: string | Uri | undefined): WorkspaceFolder { + const root = this.rootSchemaAndDomainForUri(docUri); return { uri: root.toString(), name: 'root' }; } private async fetchFolders() { - return (await getWorkspaceFolders(this.connection)) || []; + const folders = (await getWorkspaceFolders(this.connection)) || []; + return folders; } - private async _fetchVSCodeConfiguration(uri: string) { + private async _fetchVSCodeConfiguration(uri?: string) { const [cSpell, search] = ( await getConfiguration(this.connection, [{ scopeUri: uri || undefined, section: cSpellSection }, { section: 'search' }]) ).map((v) => v || {}) as [CSpellUserSettings, VsCodeSettings]; @@ -312,20 +323,22 @@ export class DocumentSettings { return cSpellConfigSettings; } - private async _fetchSettingsForUri(docUri: string): Promise { + private async _fetchSettingsForUri(docUri: string | undefined): Promise { log(`fetchFolderSettings: URI ${docUri}`); - const uri = Uri.parse(docUri); - const uriSpecial = handleSpecialUri(uri); - if (uri !== uriSpecial) { + const uri = (docUri && Uri.parse(docUri)) || undefined; + const uriSpecial = uri && handleSpecialUri(uri); + if (uriSpecial && uri !== uriSpecial) { return this.fetchSettingsForUri(uriSpecial.toString()); } - const fsPath = path.normalize(uri.fsPath); - const vscodeCSpellConfigSettingsRel = await this.fetchSettingsFromVSCode(docUri); - const vscodeCSpellConfigSettingsForDocument = await this.resolveWorkspacePaths(vscodeCSpellConfigSettingsRel, docUri); - const settings = vscodeCSpellConfigSettingsForDocument.noConfigSearch ? undefined : await searchForConfig(fsPath); - const rootFolder = this.rootFolderForUri(docUri); const folders = await this.folders; - const folder = await this.findMatchingFolder(docUri, rootFolder); + const useUriForConfig = docUri || folders[0]?.uri || defaultRootUri; + const searchForUri = Uri.parse(useUriForConfig); + const searchForFsPath = path.normalize(searchForUri.fsPath); + const vscodeCSpellConfigSettingsRel = await this.fetchSettingsFromVSCode(docUri); + const vscodeCSpellConfigSettingsForDocument = await this.resolveWorkspacePaths(vscodeCSpellConfigSettingsRel, useUriForConfig); + const settings = vscodeCSpellConfigSettingsForDocument.noConfigSearch ? undefined : await searchForConfig(searchForFsPath); + const rootFolder = this.rootSchemaAndDomainFolderForUri(docUri); + const folder = await this.findMatchingFolder(docUri, folders[0] || rootFolder); const vscodeCSpellSettings = resolveConfigImports(vscodeCSpellConfigSettingsForDocument, folder.uri); const globRootFolder = folder !== rootFolder ? folder : folders[0] || folder; @@ -342,7 +355,7 @@ export class DocumentSettings { const enabledFiletypes = extractEnableFiletypes(mergedSettings); const spellSettings = applyEnableFiletypes(enabledFiletypes, mergedSettings); - const fileSettings = calcOverrideSettings(spellSettings, fsPath); + const fileSettings = calcOverrideSettings(spellSettings, searchForFsPath); const { ignorePaths = [], files = [] } = fileSettings; const globRoot = Uri.parse(globRootFolder.uri).fsPath; @@ -361,7 +374,7 @@ export class DocumentSettings { const cSpell = vscodeCSpellConfigSettingsForDocument; const ext: ExtSettings = { - uri: docUri, + uri: useUriForConfig, vscodeSettings: { cSpell }, settings: fileSettings, excludeGlobMatcher, @@ -370,9 +383,9 @@ export class DocumentSettings { return ext; } - private async resolveWorkspacePaths(settings: CSpellUserSettings, docUri: string): Promise { + private async resolveWorkspacePaths(settings: CSpellUserSettings, docUri: string | undefined): Promise { const folders = await this.folders; - const folder = (await this.findMatchingFolder(docUri)) || folders[0] || this.rootFolderForUri(docUri); + const folder = (docUri && (await this.findMatchingFolder(docUri))) || folders[0] || this.rootSchemaAndDomainFolderForUri(docUri); const resolver = createWorkspaceNamesResolver(folder, folders, settings.workspaceRootPath); return resolveSettings(settings, resolver); } @@ -384,13 +397,13 @@ export class DocumentSettings { private createCache(loader: (key: K) => T): AutoLoadCache { const cache = createAutoLoadCache(loader); - this.cachedValues.push(cache); + this.valuesToClearOnReset.push(() => cache.clear()); return cache; } private createLazy(loader: () => T): LazyValue { const lazy = createLazyValue(loader); - this.cachedValues.push(lazy); + this.valuesToClearOnReset.push(() => lazy.clear()); return lazy; } } diff --git a/packages/_server/src/server.mts b/packages/_server/src/server.mts index c82bbb57a0..68aef9305a 100644 --- a/packages/_server/src/server.mts +++ b/packages/_server/src/server.mts @@ -29,7 +29,7 @@ import { import { TextDocument } from 'vscode-languageserver-textdocument'; import type * as Api from './api.js'; -import { onCodeActionHandler } from './codeActions.mjs'; +import { createOnCodeActionHandler } from './codeActions.mjs'; import { calculateConfigTargets } from './config/configTargetsHelper.mjs'; import { ConfigWatcher } from './config/configWatcher.mjs'; import type { CSpellUserSettings } from './config/cspellConfig/index.mjs'; @@ -47,6 +47,7 @@ import { isScmUri } from './config/docUriHelper.mjs'; import type { TextDocumentUri } from './config/vscode.config.mjs'; import { createProgressNotifier } from './progressNotifier.mjs'; import { createServerApi } from './serverApi.mjs'; +import { createOnSuggestionsHandler } from './suggestionsServer.mjs'; import { defaultIsTextLikelyMinifiedOptions, isTextLikelyMinified } from './utils/analysis.mjs'; import { catchPromise } from './utils/catchPromise.mjs'; import { debounce as simpleDebounce } from './utils/debounce.mjs'; @@ -99,7 +100,10 @@ export function run(): void { const _logger = createPrecisionLogger().setLogLevelMask(LogLevelMasks.none); - const clientServerApi = dd( + // Create a simple text document manager. + const documents = new TextDocuments(TextDocument); + + const clientServerApi: Api.ServerSideApi = dd( createServerApi( connection, { @@ -111,7 +115,10 @@ export function run(): void { getConfigurationForDocument: handleGetConfigurationForDocument, isSpellCheckEnabled: handleIsSpellCheckEnabled, splitTextIntoWords: handleSplitTextIntoWords, - spellingSuggestions: handleSpellingSuggestions, + spellingSuggestions: createOnSuggestionsHandler(documents, { + fetchSettings: getBaseSettings, + getSettingsVersion: () => documentSettings.version, + }), }, }, _logger, @@ -122,9 +129,6 @@ export function run(): void { const progressNotifier = createProgressNotifier(clientServerApi); - // Create a simple text document manager. - const documents = new TextDocuments(TextDocument); - dd( connection.onInitialize((params: InitializeParams): InitializeResult => { // Hook up the logger to the connection. @@ -271,7 +275,7 @@ export function run(): void { dd( connection.onCodeAction( - onCodeActionHandler(documents, { + createOnCodeActionHandler(documents, { fetchSettings: getBaseSettings, getSettingsVersion: () => documentSettings.version, fetchWorkspaceConfigForDocument: (uri) => documentSettings.fetchWorkspaceConfiguration(uri), @@ -319,8 +323,8 @@ export function run(): void { triggerValidateAll.next(undefined); } - function getActiveSettings(doc: TextDocumentUri) { - return getActiveUriSettings(doc.uri); + function getActiveSettings(doc: TextDocumentUri | undefined) { + return getActiveUriSettings(doc?.uri); } function getActiveUriSettings(uri?: string) { @@ -331,7 +335,7 @@ export function run(): void { // Give the dictionaries a chance to refresh if they need to. log('getActiveUriSettings', uri); catchPromise(refreshDictionaryCache(dictionaryRefreshRateMs), '__getActiveUriSettings'); - return documentSettings.getUriSettings(uri || ''); + return documentSettings.getUriSettings(uri); } async function registerConfigurationFile(path: string) { @@ -409,10 +413,6 @@ export function run(): void { }; } - async function handleSpellingSuggestions(_text: string, _docRef?: TextDocumentInfo): Promise { - return { suggestions: [] }; - } - function sendDiagnostics(result: ValidationResult) { log(`Send Diagnostics v${result.version}`, result.uri); @@ -506,7 +506,7 @@ export function run(): void { return documentSettings.calcIncludeExclude(toUri(uri)); } - async function getBaseSettings(doc: TextDocument) { + async function getBaseSettings(doc: TextDocumentUri | undefined) { const settings = await getActiveSettings(doc); return { ...CSpell.mergeSettings(defaultSettings, settings), enabledLanguageIds: settings.enabledLanguageIds }; } @@ -665,8 +665,16 @@ export function run(): void { * @param disposable - a disposable * @returns the disposable */ - function dd(disposable: T): T { - disposables.push(disposable); + function dd(disposable: T): T; + /** + * Record disposable to be disposed. + * @param disposable - a disposable + * @param moreDisposables - more disposables. + * @returns the disposable + */ + function dd(disposable: T, ...moreDisposables: T[]): T; + function dd(disposable: T, ...moreDisposables: DisposableLike[]): T { + disposables.push(disposable, ...moreDisposables); return disposable; } diff --git a/packages/_server/src/suggestionsServer.mts b/packages/_server/src/suggestionsServer.mts new file mode 100644 index 0000000000..002b6c2677 --- /dev/null +++ b/packages/_server/src/suggestionsServer.mts @@ -0,0 +1,67 @@ +import { constructSettingsForText, getDictionary } from 'cspell-lib'; +import type { TextDocuments } from 'vscode-languageserver/node.js'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; + +import type { SpellingSuggestionsResult, TextDocumentInfo } from './api.js'; +import type { CSpellUserSettings } from './config/cspellConfig/index.mjs'; +import type { GetSettingsResult } from './SuggestionsGenerator.mjs'; +import { SuggestionGenerator } from './SuggestionsGenerator.mjs'; + +export interface SuggestionsServerDependencies { + fetchSettings: (doc?: TextDocumentInfo) => Promise; + getSettingsVersion: (doc?: TextDocumentInfo) => number; +} + +export function createOnSuggestionsHandler( + documents: TextDocuments, + dependencies: SuggestionsServerDependencies, +): (word: string, doc?: TextDocumentInfo) => Promise { + const codeActionHandler = new SuggestionsServer(documents, dependencies); + + return (word: string, doc?: TextDocumentInfo) => codeActionHandler.genSuggestions(word, doc); +} + +type SettingsDictPair = GetSettingsResult; +interface CacheEntry { + docVersion: number | undefined; + settingsVersion: number; + settings: Promise; +} + +class SuggestionsServer { + private sugGen: SuggestionGenerator; + private settingsCache: Map; + + constructor( + readonly documents: TextDocuments, + readonly dependencies: SuggestionsServerDependencies, + ) { + this.settingsCache = new Map(); + this.sugGen = new SuggestionGenerator((doc) => this.getSettings(doc)); + } + + async getSettings(doc?: TextDocumentInfo): Promise { + const cached = this.settingsCache.get(doc?.uri); + const settingsVersion = this.dependencies.getSettingsVersion(doc); + if (cached?.docVersion === doc?.version && cached?.settingsVersion === settingsVersion) { + return cached.settings; + } + const settings = this.constructSettings(doc); + this.settingsCache.set(doc?.uri, { docVersion: doc?.version, settings, settingsVersion }); + return settings; + } + + private async constructSettings(doc?: TextDocumentInfo): Promise { + const document = doc && this.documents.get(doc.uri); + const text = doc?.text ?? (document?.getText() || ''); + const langId = doc?.languageId || document?.languageId || 'plaintext'; + const settings = constructSettingsForText(await this.dependencies.fetchSettings(doc), text, langId); + const dictionary = await getDictionary(settings); + return { settings, dictionary }; + } + + public async genSuggestions(word: string, doc?: TextDocumentInfo): Promise { + const suggestions = await this.sugGen.genWordSuggestions(doc, word); + return { suggestions }; + } +} diff --git a/packages/client/src/applyCorrections.ts b/packages/client/src/applyCorrections.ts new file mode 100644 index 0000000000..af77184338 --- /dev/null +++ b/packages/client/src/applyCorrections.ts @@ -0,0 +1,159 @@ +import type { Location, Range, TextDocument, Uri } from 'vscode'; +import { commands, TextEdit, window, workspace, WorkspaceEdit } from 'vscode'; +import type { Converter } from 'vscode-languageclient/lib/common/protocolConverter'; +import type { LanguageClient, TextEdit as LsTextEdit } from 'vscode-languageclient/node'; + +import * as di from './di'; +import { toRegExp } from './extensionRegEx/evaluateRegExp'; +import * as Settings from './settings'; +import { findTextDocument } from './util/findEditor'; +import { pVoid } from './util/pVoid'; + +const propertyFixSpellingWithRenameProvider = Settings.ConfigFields.fixSpellingWithRenameProvider; +const propertyUseReferenceProviderWithRename = Settings.ConfigFields['advanced.feature.useReferenceProviderWithRename']; +const propertyUseReferenceProviderRemove = Settings.ConfigFields['advanced.feature.useReferenceProviderRemove']; + +async function findLocalReference(uri: Uri, range: Range): Promise { + try { + const locations = (await commands.executeCommand('vscode.executeReferenceProvider', uri, range.start)) as Location[]; + if (!Array.isArray(locations)) return undefined; + return locations.find((loc) => loc.range.contains(range) && loc.uri.toString() === uri.toString()); + } catch (e) { + return undefined; + } +} + +async function findEditBounds(document: TextDocument, range: Range, useReference: boolean): Promise { + if (useReference) { + const refLocation = await findLocalReference(document.uri, range); + if (refLocation !== undefined) return refLocation.range; + } + + const wordRange = document.getWordRangeAtPosition(range.start); + if (!wordRange || !wordRange.contains(range)) { + return undefined; + } + return wordRange; +} + +async function applyTextEdits(client: LanguageClient, uri: Uri, edits: LsTextEdit[]): Promise { + function toTextEdit(edit: LsTextEdit): TextEdit { + return client.protocol2CodeConverter.asTextEdit(edit); + } + + const wsEdit = new WorkspaceEdit(); + const textEdits: TextEdit[] = edits.map(toTextEdit); + wsEdit.set(uri, textEdits); + try { + return await workspace.applyEdit(wsEdit); + } catch (e) { + return false; + } +} + +async function attemptRename(document: TextDocument, edit: TextEdit, refInfo: UseRefInfo): Promise { + const { range, newText: text } = edit; + if (range.start.line !== range.end.line) { + return false; + } + const { useReference, removeRegExp } = refInfo; + const wordRange = await findEditBounds(document, range, useReference); + if (!wordRange || !wordRange.contains(range)) { + return false; + } + const orig = wordRange.start.character; + const a = range.start.character - orig; + const b = range.end.character - orig; + const docText = document.getText(wordRange); + const fullNewText = [docText.slice(0, a), text, docText.slice(b)].join(''); + const newText = removeRegExp ? fullNewText.replace(removeRegExp, '') : fullNewText; + try { + const workspaceEdit = await commands + .executeCommand('vscode.executeDocumentRenameProvider', document.uri, range.start, newText) + .then( + (a) => a as WorkspaceEdit | undefined, + (reason) => (console.log(reason), false), + ); + return !!workspaceEdit && workspaceEdit.size > 0 && (await workspace.applyEdit(workspaceEdit)); + } catch (e) { + return false; + } +} + +interface UseRefInfo { + useReference: boolean; + removeRegExp: RegExp | undefined; +} + +export async function handleApplyTextEdits(uri: string, documentVersion: number, edits: LsTextEdit[]): Promise { + const client = di.get('client').client; + + console.warn('handleApplyTextEdits %o', { uri, documentVersion, edits }); + + const doc = workspace.textDocuments.find((doc) => doc.uri.toString() === uri); + + if (!doc) return; + + if (doc.version !== documentVersion) { + return pVoid( + window.showInformationMessage('Spelling changes are outdated and cannot be applied to the document.'), + 'handlerApplyTextEdits', + ); + } + + if (edits.length === 1) { + const cfg = workspace.getConfiguration(Settings.sectionCSpell, doc); + if (cfg.get(propertyFixSpellingWithRenameProvider)) { + const useReference = !!cfg.get(propertyUseReferenceProviderWithRename); + const removeRegExp = stringToRegExp(cfg.get(propertyUseReferenceProviderRemove) as string | undefined); + // console.log(`${propertyFixSpellingWithRenameProvider} Enabled`); + const edit = toTextEdit(client.protocol2CodeConverter, edits[0]); + if (await attemptRename(doc, edit, { useReference, removeRegExp })) { + return; + } + } + } + + const success = await applyTextEdits(client, doc.uri, edits); + return success + ? undefined + : pVoid(window.showErrorMessage('Failed to apply spelling changes to the document.'), 'handlerApplyTextEdits2'); +} + +function toTextEdit(converter: Converter, edit: LsTextEdit): TextEdit { + return converter.asTextEdit(edit); +} + +function stringToRegExp(regExStr: string | undefined, flags = 'g'): RegExp | undefined { + if (!regExStr) return undefined; + try { + return toRegExp(regExStr, flags); + } catch (e) { + console.log('Invalid Regular Expression: %s', regExStr); + } + return undefined; +} + +export async function handleFixSpellingIssue(docUri: Uri, text: string, withText: string, ranges: Range[]): Promise { + // console.log('handleFixSpellingIssue %o', { docUri, text, withText, ranges }); + + const document = findTextDocument(docUri); + + // check that the ranges match + for (const range of ranges) { + if (document?.getText(range) !== text) { + return failed(); + } + } + + const wsEdit = new WorkspaceEdit(); + const edits = ranges.map((range) => new TextEdit(range, withText)); + wsEdit.set(docUri, edits); + const success = await workspace.applyEdit(wsEdit); + + return success ? undefined : failed(); + + function failed() { + return pVoid(window.showErrorMessage('Failed to apply spelling changes to the document.'), 'handleFixSpellingIssue'); + } +} diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 2c45b3c745..ac3da79102 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1,5 +1,5 @@ import { setOfSupportedSchemes, supportedSchemes } from '@internal/common-utils/uriHelper'; -import type { WorkspaceConfigForDocument } from 'code-spell-checker-server/api'; +import type { SpellingSuggestionsResult, WorkspaceConfigForDocument } from 'code-spell-checker-server/api'; import type { CodeAction, Diagnostic, DiagnosticCollection, ExtensionContext, Range, TextDocument } from 'vscode'; import { Disposable, languages as vsCodeSupportedLanguages, Uri, workspace } from 'vscode'; import type { CodeActionParams, ForkOptions, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node'; @@ -235,6 +235,11 @@ export class CSpellClient implements Disposable { return actions; } + public async requestSpellingSuggestions(word: string, document: TextDocument): Promise { + const doc = { uri: document.uri.toString() }; + return this.serverApi.spellingSuggestions(word, doc); + } + public onDiagnostics(fn: (diags: DiagnosticsFromServer) => void) { return this.serverApi.onDiagnostics((pub) => { const cvt = this.client.protocol2CodeConverter; diff --git a/packages/client/src/commands.ts b/packages/client/src/commands.ts index 8630e4bc03..d1f1c3184d 100644 --- a/packages/client/src/commands.ts +++ b/packages/client/src/commands.ts @@ -1,12 +1,12 @@ -import type { Command, ConfigurationScope, Diagnostic, Disposable, Location, QuickPickOptions, TextDocument, TextEdit, Uri } from 'vscode'; -import { commands, FileType, Position, Range, Selection, TextEditorRevealType, window, workspace, WorkspaceEdit } from 'vscode'; +import type { Command, ConfigurationScope, Diagnostic, Disposable, QuickPickOptions, TextDocument, TextEdit, Uri } from 'vscode'; +import { commands, FileType, Position, Range, Selection, TextEditorRevealType, window, workspace } from 'vscode'; import type { Position as LsPosition, Range as LsRange, TextEdit as LsTextEdit } from 'vscode-languageclient/node'; -import type { ClientSideCommandHandlerApi, SpellCheckerSettingsProperties } from './client'; +import { handleApplyTextEdits, handleFixSpellingIssue } from './applyCorrections'; +import type { ClientSideCommandHandlerApi } from './client'; import { actionSuggestSpellingCorrections } from './codeActions/actionSuggestSpellingCorrections'; import * as di from './di'; import { extractMatchingDiagTexts, getCSpellDiags } from './diags'; -import { toRegExp } from './extensionRegEx/evaluateRegExp'; import type { ConfigTargetLegacy, TargetsAndScopes } from './settings'; import * as Settings from './settings'; import { @@ -120,7 +120,6 @@ const commandHandlers = { 'cSpell.addIgnoreWordsToUser': actionAddIgnoreWordToUser, 'cSpell.suggestSpellingCorrections': actionSuggestSpellingCorrections, - 'cSpell.autoFixSpellingIssues': actionAutoFixSpellingIssues, 'cSpell.goToNextSpellingIssue': () => actionJumpToSpellingError('next', false), 'cSpell.goToPreviousSpellingIssue': () => actionJumpToSpellingError('previous', false), @@ -139,7 +138,7 @@ const commandHandlers = { 'cSpell.enableCurrentLanguage': enableCurrentLanguage, 'cSpell.disableCurrentLanguage': disableCurrentLanguage, - 'cSpell.editText': handlerApplyTextEdits(), + 'cSpell.editText': handleApplyTextEdits, 'cSpell.logPerfTimeline': dumpPerfTimeline, 'cSpell.addWordToCSpellConfig': actionAddWordToCSpell, @@ -150,7 +149,10 @@ const commandHandlers = { 'cSpell.openFileAtLine': openFileAtLine, 'cSpell.selectRange': handleSelectRange, - 'cSpell.openSuggestionsForIssue': handlerResolvedLater, + 'cSpell.issueViewer.item.openSuggestionsForIssue': handlerResolvedLater, + 'cSpell.issueViewer.item.autoFixSpellingIssues': handlerResolvedLater, + 'cSpell.fixSpellingIssue': handleFixSpellingIssue, + 'cSpell.autoFixSpellingIssues': actionAutoFixSpellingIssues, } as const satisfies CommandHandler; type ImplementedCommandHandlers = typeof commandHandlers; @@ -160,10 +162,6 @@ export const knownCommands = Object.fromEntries( Object.keys(commandHandlers).map((key) => [key, key] as [ImplementedCommandNames, ImplementedCommandNames]), ) as Record; -const propertyFixSpellingWithRenameProvider: SpellCheckerSettingsProperties = 'fixSpellingWithRenameProvider'; -const propertyUseReferenceProviderWithRename: SpellCheckerSettingsProperties = 'advanced.feature.useReferenceProviderWithRename'; -const propertyUseReferenceProviderRemove: SpellCheckerSettingsProperties = 'advanced.feature.useReferenceProviderRemove'; - export function registerCommands(): Disposable[] { const registeredHandlers = Object.entries(commandHandlers).map(([cmd, fn]) => registerCmd(cmd, fn)); const registeredFromServer = Object.entries(commandsFromServer).map(([cmd, fn]) => registerCmd(cmd, fn)); @@ -172,116 +170,6 @@ export function registerCommands(): Disposable[] { function handlerResolvedLater() {} -function handlerApplyTextEdits() { - return async function handleApplyTextEdits(uri: string, documentVersion: number, edits: LsTextEdit[]): Promise { - const client = di.get('client').client; - - console.warn('handleApplyTextEdits %o', { uri, documentVersion, edits }); - - const doc = workspace.textDocuments.find((doc) => doc.uri.toString() === uri); - - if (!doc) return; - - if (doc.version !== documentVersion) { - return pVoid( - window.showInformationMessage('Spelling changes are outdated and cannot be applied to the document.'), - 'handlerApplyTextEdits', - ); - } - - if (edits.length === 1) { - const cfg = workspace.getConfiguration(Settings.sectionCSpell, doc); - if (cfg.get(propertyFixSpellingWithRenameProvider)) { - const useReference = !!cfg.get(propertyUseReferenceProviderWithRename); - const removeRegExp = toConfigToRegExp(cfg.get(propertyUseReferenceProviderRemove) as string | undefined); - // console.log(`${propertyFixSpellingWithRenameProvider} Enabled`); - const edit = client.protocol2CodeConverter.asTextEdit(edits[0]); - if (await attemptRename(doc, edit, { useReference, removeRegExp })) { - return; - } - } - } - - const success = await applyTextEdits(doc.uri, edits); - return success - ? undefined - : pVoid(window.showErrorMessage('Failed to apply spelling changes to the document.'), 'handlerApplyTextEdits2'); - }; -} - -interface UseRefInfo { - useReference: boolean; - removeRegExp: RegExp | undefined; -} - -async function attemptRename(document: TextDocument, edit: TextEdit, refInfo: UseRefInfo): Promise { - const { range, newText: text } = edit; - if (range.start.line !== range.end.line) { - return false; - } - const { useReference, removeRegExp } = refInfo; - const wordRange = await findEditBounds(document, range, useReference); - if (!wordRange || !wordRange.contains(range)) { - return false; - } - const orig = wordRange.start.character; - const a = range.start.character - orig; - const b = range.end.character - orig; - const docText = document.getText(wordRange); - const fullNewText = [docText.slice(0, a), text, docText.slice(b)].join(''); - const newText = removeRegExp ? fullNewText.replace(removeRegExp, '') : fullNewText; - try { - const workspaceEdit = await commands - .executeCommand('vscode.executeDocumentRenameProvider', document.uri, range.start, newText) - .then( - (a) => a as WorkspaceEdit | undefined, - (reason) => (console.log(reason), false), - ); - return !!workspaceEdit && workspaceEdit.size > 0 && (await workspace.applyEdit(workspaceEdit)); - } catch (e) { - return false; - } -} - -async function applyTextEdits(uri: Uri, edits: LsTextEdit[]): Promise { - const client = di.get('client').client; - function toTextEdit(edit: LsTextEdit): TextEdit { - return client.protocol2CodeConverter.asTextEdit(edit); - } - - const wsEdit = new WorkspaceEdit(); - const textEdits: TextEdit[] = edits.map(toTextEdit); - wsEdit.set(uri, textEdits); - try { - return await workspace.applyEdit(wsEdit); - } catch (e) { - return false; - } -} - -async function findLocalReference(uri: Uri, range: Range): Promise { - try { - const locations = (await commands.executeCommand('vscode.executeReferenceProvider', uri, range.start)) as Location[]; - if (!Array.isArray(locations)) return undefined; - return locations.find((loc) => loc.range.contains(range) && loc.uri.toString() === uri.toString()); - } catch (e) { - return undefined; - } -} - -async function findEditBounds(document: TextDocument, range: Range, useReference: boolean): Promise { - if (useReference) { - const refLocation = await findLocalReference(document.uri, range); - return refLocation?.range; - } - - const wordRange = document.getWordRangeAtPosition(range.start); - if (!wordRange || !wordRange.contains(range)) { - return undefined; - } - return wordRange; -} - function addWordsToConfig(words: string[], cfg: ConfigRepository) { return handleErrors(di.get('dictionaryHelper').addWordsToConfigRep(words, cfg), 'addWordsToConfig'); } @@ -657,16 +545,6 @@ function lineToRange(line: number | string | undefined) { return range; } -function toConfigToRegExp(regExStr: string | undefined, flags = 'g'): RegExp | undefined { - if (!regExStr) return undefined; - try { - return toRegExp(regExStr, flags); - } catch (e) { - console.log('Invalid Regular Expression: %s', regExStr); - } - return undefined; -} - export function createTextEditCommand( title: string, uri: string | Uri, diff --git a/packages/client/src/extension.ts b/packages/client/src/extension.ts index 94c72be2f3..b37f2c41d2 100644 --- a/packages/client/src/extension.ts +++ b/packages/client/src/extension.ts @@ -68,7 +68,7 @@ export async function activate(context: ExtensionContext): Promise const configWatcher = vscode.workspace.createFileSystemWatcher(settings.configFileLocationGlob); const decorator = new SpellingIssueDecorator(issueTracker); - activateIssueViewer(context, issueTracker); + activateIssueViewer(context, issueTracker, client); // Push the disposable to the context's subscriptions so that the // client can be deactivated on extension deactivation diff --git a/packages/client/src/issueViewer/viewer.ts b/packages/client/src/issueViewer/viewer.ts index a4e8512769..82ba3ff7c6 100644 --- a/packages/client/src/issueViewer/viewer.ts +++ b/packages/client/src/issueViewer/viewer.ts @@ -1,34 +1,62 @@ +import type { Suggestion } from 'code-spell-checker-server/api'; import { createDisposableList } from 'utils-disposables'; import type { Disposable, ExtensionContext, ProviderResult, Range, TextDocument, TreeDataProvider } from 'vscode'; -import { TreeItem } from 'vscode'; import * as vscode from 'vscode'; +import { TreeItem } from 'vscode'; +import { handleFixSpellingIssue } from '../applyCorrections'; +import type { CSpellClient } from '../client'; import { actionSuggestSpellingCorrections } from '../codeActions/actionSuggestSpellingCorrections'; import { knownCommands } from '../commands'; import type { IssueTracker, SpellingDiagnostic } from '../issueTracker'; import { createEmitter } from '../Subscribables'; +import { logErrors } from '../util/errors'; +import { findEditor } from '../util/findEditor'; -export function activate(context: ExtensionContext, issueTracker: IssueTracker) { - context.subscriptions.push(IssuesTreeDataProvider.register(issueTracker)); +export function activate(context: ExtensionContext, issueTracker: IssueTracker, client: CSpellClient) { + context.subscriptions.push(IssuesTreeDataProvider.register(issueTracker, client)); context.subscriptions.push( - vscode.commands.registerCommand(knownCommands['cSpell.openSuggestionsForIssue'], handleOpenSuggestionsForIssue), + vscode.commands.registerCommand(knownCommands['cSpell.issueViewer.item.openSuggestionsForIssue'], handleOpenSuggestionsForIssue), + vscode.commands.registerCommand(knownCommands['cSpell.issueViewer.item.autoFixSpellingIssues'], handleAutoFixSpellingIssues), ); } type OnDidChangeEventType = IssueTreeItemBase | undefined; +interface RequestSuggestionsParam { + readonly word: string; + readonly document: TextDocument; + readonly onUpdate: (suggestions: Suggestion[]) => void; +} + +interface Context { + client: CSpellClient; + issueTracker: IssueTracker; + document: TextDocument; + currentEditor: vscode.TextEditor | undefined; + invalidate: (item: OnDidChangeEventType) => void; + requestSuggestions: (item: RequestSuggestionsParam) => Suggestion[] | undefined; +} + class IssuesTreeDataProvider implements TreeDataProvider { private emitOnDidChange = createEmitter(); private disposeList = createDisposableList(); private currentEditor: vscode.TextEditor | undefined = undefined; + private suggestions = new Map(); - constructor(private issueTracker: IssueTracker) { + constructor( + private issueTracker: IssueTracker, + private client: CSpellClient, + ) { this.disposeList.push( this.emitOnDidChange, vscode.window.onDidChangeActiveTextEditor((editor) => { if (editor === this.currentEditor) return; - this.currentEditor = editor; - this.emitOnDidChange.notify(undefined); + if (editor) { + this.currentEditor = editor; + this.emitOnDidChange.notify(undefined); + } + this.currentEditor = this.currentEditor && findEditor(this.currentEditor.document.uri); }), issueTracker.onDidChangeDiagnostics((e) => { const activeTextEditor = vscode.window.activeTextEditor; @@ -54,8 +82,20 @@ class IssuesTreeDataProvider implements TreeDataProvider { getChildren(element?: IssueTreeItemBase | undefined): ProviderResult { if (element) return element.getChildren(); const editor = vscode.window.activeTextEditor; - if (!editor) return []; - return collectIssues(this.issueTracker, editor.document); + if (!editor) return [new NoIssuesTreeItem()]; + const context: Context = { + issueTracker: this.issueTracker, + client: this.client, + document: editor.document, + currentEditor: editor, + invalidate: (item) => this.emitOnDidChange.notify(item), + requestSuggestions: (item) => { + logErrors(this.fetchSuggestions(item), 'IssuesTreeDataProvider requestSuggestions'); + return this.suggestions.get(item.word); + }, + }; + const issues = collectIssues(context); + return issues.length ? issues : [new NoIssuesTreeItem()]; } onDidChangeTreeData(listener: (e: OnDidChangeEventType) => void, thisArg?: unknown, disposables?: Disposable[]): Disposable { @@ -67,10 +107,31 @@ class IssuesTreeDataProvider implements TreeDataProvider { return d; } + private async fetchSuggestions(item: RequestSuggestionsParam) { + const { word, document } = item; + const result = await this.client.requestSpellingSuggestions(word, document); + const suggestions = result.suggestions; + this.suggestions.set(word, suggestions); + // this.updateVSCodeContext(); + item.onUpdate(suggestions); + } + + // private hasPreferred(): boolean { + // for (const group of this.suggestions.values()) { + // return !!(group[0]?.isPreferred && !group[1].isPreferred); + // } + // return false; + // } + + // private updateVSCodeContext() { + // const context = this.hasPreferred() ? { hasPreferred: true } : {}; + // logErrors(vscode.commands.executeCommand('setContext', 'cspell-info.issueViewer', context), 'updateVSCodeContext'); + // } + readonly dispose = this.disposeList.dispose; - static register(issueTracker: IssueTracker, name = 'cspell-info.issuesView') { - const provider = new IssuesTreeDataProvider(issueTracker); + static register(issueTracker: IssueTracker, client: CSpellClient, name = 'cspell-info.issuesView') { + const provider = new IssuesTreeDataProvider(issueTracker, client); const disposeList = createDisposableList( [provider, vscode.window.registerTreeDataProvider(name, provider)], 'IssuesTreeDataProvider.register', @@ -82,6 +143,9 @@ class IssuesTreeDataProvider implements TreeDataProvider { const icons = { warning: new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground')), error: new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')), + doc: new vscode.ThemeIcon('go-to-file'), + suggestion: new vscode.ThemeIcon('pencil'), // new vscode.ThemeIcon('lightbulb'), + suggestionPreferred: new vscode.ThemeIcon('pencil'), // new vscode.ThemeIcon('lightbulb-autofix'), } as const; abstract class IssueTreeItemBase { @@ -90,12 +154,14 @@ abstract class IssueTreeItemBase { } class IssueTreeItem extends IssueTreeItemBase { + suggestions: Suggestion[] | undefined; constructor( - readonly uri: vscode.Uri, + readonly context: Context, readonly word: string, readonly diags: SpellingDiagnostic[] = [], ) { super(); + this.suggestions = context.requestSuggestions({ word, document: context.document, onUpdate: (sugs) => this.onUpdate(sugs) }); } addIssue(issue: SpellingDiagnostic) { @@ -104,31 +170,102 @@ class IssueTreeItem extends IssueTreeItemBase { getTreeItem(): TreeItem { const item = new TreeItem(this.word); + const hasPreferred = this.hasPreferred(); item.iconPath = this.diags[0]?.data?.isFlagged ? icons.error : icons.warning; - item.description = this.diags.length.toString(); + item.description = this.diags.length + (hasPreferred ? ' (auto fix)' : ''); if (this.diags.length === 1) { item.command = { title: 'Goto Issue', command: knownCommands['cSpell.selectRange'], - arguments: [this.uri, this.diags[0].range], + arguments: [this.context.document.uri, this.diags[0].range], }; } item.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + item.contextValue = hasPreferred ? 'issue.hasPreferred' : 'issue'; return item; } getChildren() { - return this.diags.map((d) => new IssueInstanceTreeItem(this.uri, this.word, d)); + const { context, word, diags, suggestions } = this; + return [new IssueLocationsTreeItem(context, word, diags), new IssueFixesTreeItem(this.context, word, diags, suggestions)]; } getRange(): Range | undefined { return this.diags[0]?.range; } + + hasPreferred(): boolean { + const preferred = this.suggestions?.filter((sug) => sug.isPreferred); + return preferred?.length === 1; + } + + getPreferred(): PreferredFix | undefined { + const preferred = this.suggestions?.filter((sug) => sug.isPreferred); + if (preferred?.length !== 1) return undefined; + const { word } = preferred[0]; + return { + text: this.word, + newText: word, + ranges: this.diags.map((d) => d.range), + }; + } + + async autoFix() { + const pref = this.getPreferred(); + if (!pref) return; + return handleFixSpellingIssue(this.context.document.uri, pref.text, pref.newText, pref.ranges); + } + + private onUpdate(suggestions: Suggestion[]) { + this.suggestions = suggestions; + this.context.invalidate(this); + } +} + +class IssueLocationsTreeItem extends IssueTreeItemBase { + constructor( + readonly context: Context, + readonly word: string, + readonly diags: SpellingDiagnostic[], + ) { + super(); + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Locations:'); + item.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + return item; + } + + getChildren() { + return this.diags.map((d) => new IssueLocationTreeItem(this.context, this.word, d)); + } } -class IssueInstanceTreeItem extends IssueTreeItemBase { +class IssueFixesTreeItem extends IssueTreeItemBase { + suggestions: Suggestion[] | undefined; constructor( - readonly uri: vscode.Uri, + readonly context: Context, + readonly word: string, + readonly diags: SpellingDiagnostic[], + suggestions: Suggestion[] | undefined, + ) { + super(); + this.suggestions = suggestions; + } + + getTreeItem(): TreeItem { + return new TreeItem('Fixes:', vscode.TreeItemCollapsibleState.Expanded); + } + + getChildren() { + return this.suggestions?.map((sug) => new IssueSuggestionTreeItem(this.context, this.word, sug, this.diags)); + } +} + +class IssueLocationTreeItem extends IssueTreeItemBase { + constructor( + readonly context: Context, readonly word: string, readonly diag: SpellingDiagnostic, ) { @@ -137,14 +274,15 @@ class IssueInstanceTreeItem extends IssueTreeItemBase { getTreeItem(): TreeItem { const range = this.diag.range; - const item = new TreeItem(`${range.start.line}:${range.start.character}`); - // item.iconPath = icons.warning; + const item = new TreeItem(`${range.start.line + 1}:${range.start.character + 1}`); + item.iconPath = icons.doc; item.description = this.word; item.command = { title: 'Goto Issue', command: knownCommands['cSpell.selectRange'], - arguments: [this.uri, this.diag.range], + arguments: [this.context.document.uri, this.diag.range], }; + item.contextValue = 'issue.location'; return item; } @@ -157,10 +295,65 @@ class IssueInstanceTreeItem extends IssueTreeItemBase { } } -function collectIssues(issueTracker: IssueTracker, doc: TextDocument): IssueTreeItem[] { - const issues = issueTracker.getDiagnostics(doc.uri); +class IssueSuggestionTreeItem extends IssueTreeItemBase { + constructor( + readonly context: Context, + readonly word: string, + readonly suggestion: Suggestion, + readonly diags: SpellingDiagnostic[], + ) { + super(); + } + + getTreeItem(): TreeItem { + const { word, isPreferred } = this.suggestion; + const item = new TreeItem(word); + item.iconPath = isPreferred ? icons.suggestionPreferred : icons.suggestion; + item.description = isPreferred && '(preferred)'; + const fixMessage = 'Fix Issue with ' + word; + item.command = { + title: fixMessage, + command: knownCommands['cSpell.fixSpellingIssue'], + arguments: [this.context.document.uri, this.word, word, this.diags.map((d) => d.range)], + }; + item.tooltip = fixMessage; + item.accessibilityInformation = { label: fixMessage }; + item.contextValue = isPreferred ? 'issue.suggestion-preferred' : 'issue.suggestion'; + // item.checkboxState = { + // state: vscode.TreeItemCheckboxState.Unchecked, + // tooltip: fixMessage, + // }; + return item; + } + + getChildren() { + return undefined; + } + + isPreferred(): boolean { + return this.suggestion.isPreferred || false; + } +} + +class NoIssuesTreeItem extends IssueTreeItemBase { + constructor() { + super(); + } + + getTreeItem(): TreeItem { + return new TreeItem('No Issues Found...'); + } + + getChildren() { + return undefined; + } +} + +function collectIssues(context: Context): IssueTreeItem[] { + const doc = context.document; + const issues = context.issueTracker.getDiagnostics(doc.uri); const groupedByWord = new Map(); - const getGroup = getResolve(groupedByWord, (word) => new IssueTreeItem(doc.uri, word)); + const getGroup = getResolve(groupedByWord, (word) => new IssueTreeItem(context, word)); issues.forEach(groupIssue); const comp = new Intl.Collator().compare; @@ -189,7 +382,23 @@ function getResolve(map: Map, resolver: (k: K) => V): (k: K) => V { }; } -function handleOpenSuggestionsForIssue(item?: IssueTreeItem | IssueInstanceTreeItem) { - if (!item || !(item instanceof IssueTreeItemBase)) return; - return actionSuggestSpellingCorrections(item.uri, item.getRange(), item.word); +function handleOpenSuggestionsForIssue(item?: IssueTreeItem) { + if (!(item instanceof IssueTreeItemBase)) return; + return actionSuggestSpellingCorrections(item.context.document.uri, item.getRange(), item.word); } + +function handleAutoFixSpellingIssues(item?: IssueTreeItem) { + if (!(item instanceof IssueTreeItem)) return; + return item.autoFix(); +} + +interface PreferredFix { + text: string; + newText: string; + ranges: Range[]; +} + +// function getPreferredFixes(issueTracker: IssueTracker, uri: Uri): PreferredFix[] | undefined { +// const issues = issueTracker.getDiagnostics(uri); +// if (!issues.length) return undefined; +// } diff --git a/packages/client/src/util/findEditor.ts b/packages/client/src/util/findEditor.ts index 4b8951e0f8..2335bf3b11 100644 --- a/packages/client/src/util/findEditor.ts +++ b/packages/client/src/util/findEditor.ts @@ -1,5 +1,5 @@ -import type { TextEditor, Uri } from 'vscode'; -import { window } from 'vscode'; +import type { TextDocument, TextEditor, Uri } from 'vscode'; +import { window, workspace } from 'vscode'; export function findEditor(uri?: Uri): TextEditor | undefined { if (!uri) return window.activeTextEditor; @@ -14,3 +14,17 @@ export function findEditor(uri?: Uri): TextEditor | undefined { return undefined; } + +export function findTextDocument(uri?: Uri): TextDocument | undefined { + if (!uri) return undefined; + + const uriStr = uri.toString(); + + for (const document of workspace.textDocuments) { + if (document.uri.toString() === uriStr) { + return document; + } + } + + return undefined; +}