diff --git a/extensions/vscode/src/common.ts b/extensions/vscode/src/common.ts index 0adef5beb4..46960764f6 100644 --- a/extensions/vscode/src/common.ts +++ b/extensions/vscode/src/common.ts @@ -16,6 +16,7 @@ import { config } from './config'; import * as componentMeta from './features/componentMeta'; import * as doctor from './features/doctor'; import * as nameCasing from './features/nameCasing'; +import * as dragImport from './features/dragImport'; import * as splitEditors from './features/splitEditors'; let semanticClient: lsp.BaseLanguageClient; @@ -93,6 +94,7 @@ async function doActivate(context: vscode.ExtensionContext, createLc: CreateLang splitEditors.register(context, syntacticClient); doctor.register(context, semanticClient); componentMeta.register(context, semanticClient); + dragImport.register(context, semanticClient); const supportedLanguages: Record = { vue: true, diff --git a/extensions/vscode/src/features/dragImport.ts b/extensions/vscode/src/features/dragImport.ts new file mode 100644 index 0000000000..46878bd4ed --- /dev/null +++ b/extensions/vscode/src/features/dragImport.ts @@ -0,0 +1,64 @@ +import { GetDragAndDragImportEditsRequest, TagNameCasing } from '@vue/language-server'; +import * as vscode from 'vscode'; +import type { BaseLanguageClient, DocumentFilter, InsertTextFormat } from 'vscode-languageclient'; +import { tagNameCasings } from './nameCasing'; +import { config } from '../config'; + +export async function register(context: vscode.ExtensionContext, client: BaseLanguageClient) { + + const selectors: DocumentFilter[] = [{ language: 'vue' }]; + + if (config.server.petiteVue.supportHtmlFile) { + selectors.push({ language: 'html' }); + } + if (config.server.vitePress.supportMdFile) { + selectors.push({ language: 'markdown' }); + } + + context.subscriptions.push( + vscode.languages.registerDocumentDropEditProvider( + selectors, + { + async provideDocumentDropEdits(document, _position, dataTransfer) { + for (const [mimeType, item] of dataTransfer) { + if (mimeType === 'text/uri-list') { + const uri = item.value as string; + if ( + uri.endsWith('.vue') + || (uri.endsWith('.md') && config.server.vitePress.supportMdFile) + ) { + const response = await client.sendRequest(GetDragAndDragImportEditsRequest.type, { + uri: document.uri.toString(), + importUri: uri, + casing: tagNameCasings.get(document.uri.toString()) ?? TagNameCasing.Pascal, + }); + if (!response) { + return; + } + const additionalEdit = new vscode.WorkspaceEdit(); + for (const edit of response.additionalEdits) { + additionalEdit.replace( + document.uri, + new vscode.Range( + edit.range.start.line, + edit.range.start.character, + edit.range.end.line, + edit.range.end.character, + ), + edit.newText + ); + } + return { + insertText: response.insertTextFormat === 2 satisfies typeof InsertTextFormat.Snippet + ? new vscode.SnippetString(response.insertText) + : response.insertText, + additionalEdit, + }; + } + } + } + }, + } + ), + ); +} diff --git a/packages/language-server/src/languageServerPlugin.ts b/packages/language-server/src/languageServerPlugin.ts index 6c46c45cb9..7ae55990d5 100644 --- a/packages/language-server/src/languageServerPlugin.ts +++ b/packages/language-server/src/languageServerPlugin.ts @@ -3,7 +3,7 @@ import { LanguageServerPlugin, Connection } from '@volar/language-server'; import * as vue from '@vue/language-service'; import * as vue2 from '@vue/language-core'; import * as nameCasing from '@vue/language-service'; -import { DetectNameCasingRequest, GetConvertAttrCasingEditsRequest, GetConvertTagCasingEditsRequest, ParseSFCRequest, GetComponentMeta } from './protocol'; +import { DetectNameCasingRequest, GetConvertAttrCasingEditsRequest, GetConvertTagCasingEditsRequest, ParseSFCRequest, GetComponentMeta, GetDragAndDragImportEditsRequest } from './protocol'; import { VueServerInitializationOptions } from './types'; import type * as ts from 'typescript/lib/tsserverlibrary'; import * as componentMeta from 'vue-component-meta/out/base'; @@ -100,6 +100,13 @@ export function createServerPlugin(connection: Connection) { } }); + connection.onRequest(GetDragAndDragImportEditsRequest.type, async params => { + const languageService = await getService(params.uri); + if (languageService) { + return nameCasing.getDragImportEdits(ts, languageService.context, params.uri, params.importUri, params.casing); + } + }); + connection.onRequest(GetConvertAttrCasingEditsRequest.type, async params => { const languageService = await getService(params.textDocument.uri); if (languageService) { diff --git a/packages/language-server/src/protocol.ts b/packages/language-server/src/protocol.ts index a36d7c0752..e7638c67f8 100644 --- a/packages/language-server/src/protocol.ts +++ b/packages/language-server/src/protocol.ts @@ -31,6 +31,21 @@ export namespace GetConvertTagCasingEditsRequest { export const type = new vscode.RequestType('vue/convertTagNameCasing'); } +export namespace GetDragAndDragImportEditsRequest { + export type ParamsType = { + uri: string, + importUri: string, + casing: TagNameCasing, + }; + export type ResponseType = { + insertText: string; + insertTextFormat: vscode.InsertTextFormat; + additionalEdits: vscode.TextEdit[]; + } | null | undefined; + export type ErrorType = never; + export const type = new vscode.RequestType('vue/dragImportEdits'); +} + export namespace GetConvertAttrCasingEditsRequest { export type ParamsType = { textDocument: vscode.TextDocumentIdentifier, diff --git a/packages/language-service/src/ideFeatures/dragImport.ts b/packages/language-service/src/ideFeatures/dragImport.ts new file mode 100644 index 0000000000..e4d2c3d192 --- /dev/null +++ b/packages/language-service/src/ideFeatures/dragImport.ts @@ -0,0 +1,65 @@ +import { ServiceContext } from '@volar/language-service'; +import { VueFile } from '@vue/language-core'; +import { camelize, capitalize, hyphenate } from '@vue/shared'; +import { posix as path } from 'path'; +import type * as vscode from 'vscode-languageserver-protocol'; +import { createAddComponentToOptionEdit, getLastImportNode } from '../plugins/vue-extract-file'; +import { TagNameCasing } from '../types'; + +export function getDragImportEdits( + ts: typeof import('typescript/lib/tsserverlibrary'), + ctx: ServiceContext, + uri: string, + importUri: string, + casing: TagNameCasing +): { + insertText: string; + insertTextFormat: vscode.InsertTextFormat; + additionalEdits: vscode.TextEdit[]; +} | undefined { + + let baseName = importUri.substring(importUri.lastIndexOf('/') + 1); + baseName = baseName.substring(0, baseName.lastIndexOf('.')); + + const newName = capitalize(camelize(baseName)); + const document = ctx!.getTextDocument(uri)!; + const [vueFile] = ctx!.documents.getVirtualFileByUri(document.uri) as [VueFile, any]; + const { sfc } = vueFile; + const script = sfc.scriptSetup ?? sfc.script; + + if (!sfc.template || !script) + return; + + const lastImportNode = getLastImportNode(ts, script.ast); + const edits: vscode.TextEdit[] = [ + { + range: lastImportNode ? { + start: document.positionAt(script.startTagEnd + lastImportNode.end), + end: document.positionAt(script.startTagEnd + lastImportNode.end), + } : { + start: document.positionAt(script.startTagEnd), + end: document.positionAt(script.startTagEnd), + }, + newText: `\nimport ${newName} from './${path.relative(path.dirname(uri), importUri) || importUri.substring(importUri.lastIndexOf('/') + 1)}'`, + }, + ]; + + if (sfc.script) { + const edit = createAddComponentToOptionEdit(ts, sfc.script.ast, newName); + if (edit) { + edits.push({ + range: { + start: document.positionAt(sfc.script.startTagEnd + edit.range.start), + end: document.positionAt(sfc.script.startTagEnd + edit.range.end), + }, + newText: edit.newText, + }); + } + } + + return { + insertText: `<${casing === TagNameCasing.Kebab ? hyphenate(newName) : newName}$0 />`, + insertTextFormat: 2 satisfies typeof vscode.InsertTextFormat.Snippet, + additionalEdits: edits, + }; +} diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts index 75191f5bb7..4626d9053b 100644 --- a/packages/language-service/src/index.ts +++ b/packages/language-service/src/index.ts @@ -1,6 +1,7 @@ export * from '@volar/language-service'; export * from '@vue/language-core'; export * from './ideFeatures/nameCasing'; +export * from './ideFeatures/dragImport'; export * from './languageService'; export { TagNameCasing, AttrNameCasing } from './types'; export { Provide } from './plugins/vue'; diff --git a/packages/language-service/src/plugins/vue-extract-file.ts b/packages/language-service/src/plugins/vue-extract-file.ts index 5c359ede3b..615640b970 100644 --- a/packages/language-service/src/plugins/vue-extract-file.ts +++ b/packages/language-service/src/plugins/vue-extract-file.ts @@ -82,7 +82,7 @@ export const create = function (): Service { const toExtract = collectExtractProps(); const initialIndentSetting = await ctx!.env.getConfiguration!('volar.format.initialIndent') as Record; const newUri = document.uri.substring(0, document.uri.lastIndexOf('/') + 1) + `${newName}.vue`; - const lastImportNode = getLastImportNode(script.ast); + const lastImportNode = getLastImportNode(ts, script.ast); let newFileTags = []; @@ -169,22 +169,6 @@ export const create = function (): Service { }, }; - function getLastImportNode(sourceFile: ts.SourceFile) { - - let lastImportNode: ts.Node | undefined; - - for (const statement of sourceFile.statements) { - if (ts.isImportDeclaration(statement)) { - lastImportNode = statement; - } - else { - break; - } - } - - return lastImportNode; - } - function collectExtractProps() { const result = new Map