diff --git a/packages/autocomplete/src/create.ts b/packages/autocomplete/src/create.ts index 39257b0174..643658ce64 100644 --- a/packages/autocomplete/src/create.ts +++ b/packages/autocomplete/src/create.ts @@ -2,10 +2,10 @@ import type { AutoCompleteExtractorResult, AutoCompleteFunction, AutoCompleteTem import { escapeRegExp, toArray, uniq } from '@unocss/core' import LRU from 'lru-cache' import { parseAutocomplete } from './parse' -import type { ParsedAutocompleteTemplate } from './types' +import type { ParsedAutocompleteTemplate, UnocssAutocomplete } from './types' import { searchUsageBoundary } from './utils' -export function createAutocomplete(uno: UnoGenerator) { +export function createAutocomplete(uno: UnoGenerator): UnocssAutocomplete { const templateCache = new Map() const cache = new LRU({ max: 5000 }) diff --git a/packages/autocomplete/src/types.ts b/packages/autocomplete/src/types.ts index a5b31a5e46..485a32de0b 100644 --- a/packages/autocomplete/src/types.ts +++ b/packages/autocomplete/src/types.ts @@ -1,3 +1,6 @@ +import type { AutoCompleteFunction, SuggestResult } from '@unocss/core' +import type LRU from 'lru-cache' + export type AutocompleteTemplatePart = AutocompleteTemplateStatic | AutocompleteTemplateGroup | AutocompleteTemplateTheme export interface AutocompleteTemplateStatic { @@ -19,3 +22,12 @@ export interface ParsedAutocompleteTemplate { parts: AutocompleteTemplatePart[] suggest(input: string): string[] | undefined } + +export interface UnocssAutocomplete { + suggest: (input: string) => Promise + suggestInFile: (content: string, cursor: number) => Promise + templates: (string | AutoCompleteFunction)[] + cache: LRU + reset: () => void + enumerate: () => Promise> +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a0fff75b94..f487028db4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -466,6 +466,7 @@ export interface UnocssPluginContext { reloadConfig: () => Promise> getConfig: () => Promise + onReload: (fn: () => void) => void invalidate: () => void onInvalidate: (fn: () => void) => void diff --git a/packages/shared-integration/src/context.ts b/packages/shared-integration/src/context.ts index 11c9c9a572..b332039170 100644 --- a/packages/shared-integration/src/context.ts +++ b/packages/shared-integration/src/context.ts @@ -18,6 +18,7 @@ export function createContext = UserConfig>( let rollupFilter = createFilter(defaultInclude, defaultExclude) const invalidations: Array<() => void> = [] + const reloadListeners: Array<() => void> = [] const modules = new BetterMap() const tokens = new Set() @@ -38,6 +39,7 @@ export function createContext = UserConfig>( tokens.clear() await Promise.all(modules.map((code, id) => uno.applyExtractors(code, id, tokens))) invalidate() + dispatchReload() // check preset duplication const presets = new Set() @@ -65,6 +67,10 @@ export function createContext = UserConfig>( invalidations.forEach(cb => cb()) } + function dispatchReload() { + reloadListeners.forEach(cb => cb()) + } + async function extract(code: string, id?: string) { if (id) modules.set(id, code) @@ -97,6 +103,9 @@ export function createContext = UserConfig>( }, filter, reloadConfig, + onReload(fn: () => void) { + reloadListeners.push(fn) + }, uno, extract, getConfig, diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 9a1c14ef5c..355fb8943f 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -28,7 +28,13 @@ "workspaceContains:**/package.json" ], "contributes": { - "commands": [], + "commands": [ + { + "command": "unocss.reload", + "title": "Reload UnoCSS", + "category": "UnoCSS" + } + ], "configuration": { "type": "object", "title": "UnoCSS", diff --git a/packages/vscode/src/annonation.ts b/packages/vscode/src/annonation.ts index 2dc6f81122..ec2df347f0 100644 --- a/packages/vscode/src/annonation.ts +++ b/packages/vscode/src/annonation.ts @@ -1,19 +1,17 @@ -import { relative } from 'path' +import path from 'path' import type { DecorationOptions, ExtensionContext, StatusBarItem } from 'vscode' import { DecorationRangeBehavior, MarkdownString, Range, window, workspace } from 'vscode' -import type { UnocssPluginContext } from '@unocss/core' import { INCLUDE_COMMENT_IDE, getMatchedPositions } from './integration' import { log } from './log' import { getPrettiedMarkdown, isCssId, throttle } from './utils' +import type { ContextLoader } from './contextLoader' export async function registerAnnonations( cwd: string, - context: UnocssPluginContext, + contextLoader: ContextLoader, status: StatusBarItem, ext: ExtensionContext, ) { - const { sources } = await context.ready - const { uno, filter } = context let underline: boolean = workspace.getConfiguration().get('unocss.underline') ?? true ext.subscriptions.push(workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration('unocss.underline')) { @@ -23,10 +21,14 @@ export async function registerAnnonations( })) workspace.onDidSaveTextDocument(async (doc) => { - if (sources.includes(doc.uri.fsPath)) { + const id = doc.uri.fsPath + const dir = path.dirname(id) + + if (contextLoader.contexts.has(dir)) { + const ctx = contextLoader.contexts.get(dir)! try { - await context.reloadConfig() - log.appendLine(`Config reloaded by ${relative(cwd, doc.uri.fsPath)}`) + await ctx.reloadConfig() + log.appendLine(`Config reloaded by ${path.relative(cwd, doc.uri.fsPath)}`) } catch (e) { log.appendLine('Error on loading config') @@ -54,17 +56,23 @@ export async function registerAnnonations( const code = doc.getText() const id = doc.uri.fsPath - if (!code || (!code.includes(INCLUDE_COMMENT_IDE) && !isCssId(id) && !filter(code, id))) + if (!code) return reset() - const result = await uno.generate(code, { id, preflights: false, minify: true }) + let ctx = await contextLoader.resolveContext(code, id) + if (!ctx && (code.includes(INCLUDE_COMMENT_IDE) || isCssId(id))) + ctx = await contextLoader.resolveCloestContext(code, id) + else if (!ctx?.filter(code, id)) + return null + + const result = await ctx.uno.generate(code, { id, preflights: false, minify: true }) const ranges: DecorationOptions[] = ( await Promise.all( getMatchedPositions(code, Array.from(result.matched)) .map(async (i): Promise => { try { - const md = await getPrettiedMarkdown(uno, i[2]) + const md = await getPrettiedMarkdown(ctx!.uno, i[2]) return { range: new Range(doc.positionAt(i[0]), doc.positionAt(i[1])), get hoverMessage() { @@ -113,6 +121,9 @@ export async function registerAnnonations( if (e.document === window.activeTextEditor?.document) throttledUpdateAnnotation() }) + contextLoader.events.on('reload', async () => { + await updateAnnotation() + }) await updateAnnotation() } diff --git a/packages/vscode/src/autocomplete.ts b/packages/vscode/src/autocomplete.ts index bfab4746a4..ef07efaf03 100644 --- a/packages/vscode/src/autocomplete.ts +++ b/packages/vscode/src/autocomplete.ts @@ -1,9 +1,11 @@ -import type { UnocssPluginContext } from '@unocss/core' +import type { UnocssAutocomplete } from '@unocss/autocomplete' import { createAutocomplete } from '@unocss/autocomplete' -import type { CompletionItemProvider, ExtensionContext, Position, TextDocument } from 'vscode' +import type { CompletionItemProvider, ExtensionContext } from 'vscode' import { CompletionItem, CompletionItemKind, CompletionList, MarkdownString, Range, languages } from 'vscode' +import type { UnoGenerator, UnocssPluginContext } from '@unocss/core' import { getPrettiedMarkdown, isCssId } from './utils' import { log } from './log' +import type { ContextLoader } from './contextLoader' const languageIds = [ 'erb', @@ -24,27 +26,59 @@ const languageIds = [ ] const delimiters = ['-', ':'] +class UnoCompletionItem extends CompletionItem { + uno: UnoGenerator + + constructor(label: string, kind: CompletionItemKind, uno: UnoGenerator) { + super(label, kind) + this.uno = uno + } +} + export async function registerAutoComplete( - context: UnocssPluginContext, + contextLoader: ContextLoader, ext: ExtensionContext, ) { - const { uno, filter } = context + const autoCompletes = new Map() + contextLoader.events.on('contextReload', (ctx) => { + autoCompletes.delete(ctx) + }) + contextLoader.events.on('contextUnload', (ctx) => { + autoCompletes.delete(ctx) + }) + + function getAutocomplete(ctx: UnocssPluginContext) { + const cached = autoCompletes.get(ctx) + if (cached) + return cached - const autoComplete = createAutocomplete(uno) + const autocomplete = createAutocomplete(ctx.uno) - async function getMarkdown(util: string) { + autoCompletes.set(ctx, autocomplete) + return autocomplete + } + + async function getMarkdown(uno: UnoGenerator, util: string) { return new MarkdownString(await getPrettiedMarkdown(uno, util)) } - const provider: CompletionItemProvider = { - async provideCompletionItems(doc: TextDocument, position: Position) { + const provider: CompletionItemProvider = { + async provideCompletionItems(doc, position) { const code = doc.getText() const id = doc.uri.fsPath - if (!code || (!isCssId(id) && !filter(code, id))) + if (!code) + return null + + let ctx = await contextLoader.resolveContext(code, id) + if (!ctx && isCssId(id)) + ctx = await contextLoader.resolveCloestContext(code, id) + else if (!ctx?.filter(code, id)) return null try { + const autoComplete = getAutocomplete(ctx) + const result = await autoComplete.suggestInFile(code, doc.offsetAt(position)) log.appendLine(`[autocomplete] ${id} | ${result.suggestions.slice(0, 10).map(v => `[${v[0]}, ${v[1]}]`).join(', ')}`) @@ -54,7 +88,7 @@ export async function registerAutoComplete( return new CompletionList(result.suggestions.map(([value, label]) => { const resolved = result.resolveReplacement(value) - const item = new CompletionItem(label, CompletionItemKind.EnumMember) + const item = new UnoCompletionItem(label, CompletionItemKind.EnumMember, ctx!.uno) item.insertText = resolved.replacement item.range = new Range(doc.positionAt(resolved.start), doc.positionAt(resolved.end)) return item @@ -66,10 +100,10 @@ export async function registerAutoComplete( } }, - async resolveCompletionItem(item: CompletionItem) { + async resolveCompletionItem(item) { return { ...item, - documentation: await getMarkdown(item.label as string), + documentation: await getMarkdown(item.uno, item.label as string), } }, } diff --git a/packages/vscode/src/contextLoader.ts b/packages/vscode/src/contextLoader.ts new file mode 100644 index 0000000000..59ac964ab4 --- /dev/null +++ b/packages/vscode/src/contextLoader.ts @@ -0,0 +1,220 @@ +import { readdir } from 'fs/promises' +import path from 'path' +import type { UnocssPluginContext, UserConfig } from '@unocss/core' +import { sourceObjectFields, sourcePluginFactory } from 'unconfig/presets' +import presetUno from '@unocss/preset-uno' +import { resolveOptions as resolveNuxtOptions } from '../../nuxt/src/options' +import { createNanoEvents } from '../../core/src/utils/events' +import { createContext } from './integration' +import { isSubdir } from './utils' +import { log } from './log' + +export class ContextLoader { + public cwd: string + public ready: Promise + public defaultContext: UnocssPluginContext> + public contexts = new Map>>() + private fileContextCache = new Map> | null>() + private configExistsCache = new Map() + public events = createNanoEvents<{ + reload: () => void + contextLoaded: (context: UnocssPluginContext>) => void + contextReload: (context: UnocssPluginContext>) => void + contextUnload: (context: UnocssPluginContext>) => void + }>() + + constructor(cwd: string) { + this.cwd = cwd + this.defaultContext = createContext({ + presets: [ + presetUno(), + ], + }) + + this.ready = this.reload() + .then(async () => { + await this.defaultContext.ready + }) + } + + async reload() { + this.ready = this._reload() + await this.ready + this.events.emit('reload') + } + + private async _reload() { + for (const dir of this.contexts.keys()) + this.unloadContext(dir) + this.fileContextCache.clear() + this.configExistsCache.clear() + + await this.loadConfigInDirectory(this.cwd) + } + + async unloadContext(configDir: string) { + const context = this.contexts.get(configDir) + if (!context) + return + + this.contexts.delete(configDir) + + for (const [path, ctx] of this.fileContextCache) { + if (ctx === context) + this.fileContextCache.delete(path) + } + + this.events.emit('contextUnload', context) + } + + async configExists(dir: string) { + const files = await readdir(dir) + return files.some(f => /^(vite|svelte|astro|iles|nuxt|unocss|uno)\.config/.test(f)) + } + + async loadConfigInDirectory(dir: string) { + const cached = this.contexts.get(dir) + if (cached) + return cached + + const context = createContext( + dir, + undefined, + [ + sourcePluginFactory({ + files: [ + 'vite.config', + 'svelte.config', + 'astro.config', + 'iles.config', + ], + targetModule: 'unocss/vite', + parameters: [{ command: 'serve', mode: 'development' }], + }), + sourceObjectFields({ + files: 'nuxt.config', + fields: 'unocss', + }), + ], + (result) => { + if (result.sources.some(s => s.includes('nuxt.config'))) + resolveNuxtOptions(result.config) + }, + ) + + context.updateRoot(dir) + + let sources = [] + try { + sources = (await context.ready).sources + } + catch (e) { + log.appendLine(`[error] ${String(e)}`) + log.appendLine(`[error] Error occurred while loading config. Config directory: ${dir}`) + return null + } + + if (!sources.length) + return null + + const baseDir = path.dirname(sources[0]) + if (this.contexts.has(baseDir)) + return this.contexts.get(baseDir)! + + this.configExistsCache.set(baseDir, true) + + context.onReload(() => { + for (const [path, ctx] of this.fileContextCache) { + if (ctx === context || !ctx) + this.fileContextCache.delete(path) + } + + this.configExistsCache.clear() + this.events.emit('contextReload', context) + }) + + for (const [path, ctx] of this.fileContextCache) { + if (!ctx) + this.fileContextCache.delete(path) + } + + this.events.emit('contextLoaded', context) + + log.appendLine(`[info] New configuration loaded from\n ${sources.map(s => ` - ${s}`).join('\n')}`) + + this.contexts.set(baseDir, context) + + return context + } + + async resolveContext(code: string, file: string) { + const cached = this.fileContextCache.get(file) + if (cached !== undefined) + return cached + + log.appendLine(`[info] Resolving config for ${file}`) + + // try finding an existing context that includes the file + for (const [configDir, context] of this.contexts) { + if (!isSubdir(configDir, file)) + continue + + if (!context.filter(code, file)) + continue + + this.fileContextCache.set(file, context) + return context + } + + // try finding a config from disk + let dir = path.dirname(file) + while (isSubdir(this.cwd, dir)) { + if (this.configExistsCache.get(dir) === false) + continue + + if (!this.configExists(dir)) { + this.configExistsCache.set(dir, false) + continue + } + + const context = await this.loadConfigInDirectory(dir) + this.configExistsCache.set(dir, !!context) + + if (context?.filter(code, file)) { + this.fileContextCache.set(file, context) + return context + } + + dir = path.dirname(dir) + } + + this.fileContextCache.set(file, null) + return null + } + + async resolveCloestContext(code: string, file: string) { + const cached = this.fileContextCache.get(file) + if (cached) + return cached + + for (const [configDir, context] of this.contexts) { + if (!isSubdir(configDir, file)) + continue + + if (!context.filter(code, file)) + continue + + this.fileContextCache.set(file, context) + return context + } + + for (const [configDir, context] of this.contexts) { + if (isSubdir(configDir, file)) { + this.fileContextCache.set(file, context) + return context + } + } + + return this.defaultContext + } +} diff --git a/packages/vscode/src/index.ts b/packages/vscode/src/index.ts index 7c0ab2ebca..e38be2a039 100644 --- a/packages/vscode/src/index.ts +++ b/packages/vscode/src/index.ts @@ -1,14 +1,11 @@ -import { relative, resolve } from 'path' +import path from 'path' import type { ExtensionContext } from 'vscode' -import { StatusBarAlignment, window, workspace } from 'vscode' -import { sourceObjectFields, sourcePluginFactory } from 'unconfig/presets' -import presetUno from '@unocss/preset-uno' +import { StatusBarAlignment, commands, window, workspace } from 'vscode' import { version } from '../package.json' -import { resolveOptions as resolveNuxtOptions } from '../../nuxt/src/options' -import { createContext } from './integration' import { log } from './log' import { registerAnnonations } from './annonation' import { registerAutoComplete } from './autocomplete' +import { ContextLoader } from './contextLoader' export async function activate(ext: ExtensionContext) { const projectPath = workspace.workspaceFolders?.[0].uri.fsPath @@ -17,64 +14,23 @@ export async function activate(ext: ExtensionContext) { const config = workspace.getConfiguration('unocss') const root = config.get('root') - const cwd = root ? resolve(projectPath, root) : projectPath + const cwd = root ? path.resolve(projectPath, root) : projectPath log.appendLine(`UnoCSS for VS Code v${version} ${process.cwd()}`) - const context = createContext( - cwd, - { - presets: [ - presetUno(), - ], - }, - [ - sourcePluginFactory({ - files: [ - 'vite.config', - 'svelte.config', - 'astro.config', - 'iles.config', - ], - targetModule: 'unocss/vite', - parameters: [{ command: 'serve', mode: 'development' }], - }), - sourceObjectFields({ - files: 'nuxt.config', - fields: 'unocss', - }), - ], - (result) => { - if (result.sources.some(s => s.includes('nuxt.config'))) - resolveNuxtOptions(result.config) - }, - ) - - context.updateRoot(cwd) - - let sources: string[] = [] - try { - sources = (await context.ready).sources - } - catch (e) { - log.appendLine(`[error] ${String(e)}`) - log.appendLine('[error] Failed to start extension, exiting') - return - } - - if (!sources.length) { - log.appendLine('[warn] No config files found, disabled') - log.appendLine('[warn] Make sure you have `unocss.config.js` in your workspace root, or change `unocss.root` in your workspace settings') - return - } - - log.appendLine(`Configuration loaded from\n${sources.map(s => ` - ${relative(cwd, s)}`).join('\n')}`) + const contextLoader = new ContextLoader(cwd) + await contextLoader.ready const status = window.createStatusBarItem(StatusBarAlignment.Right, 200) status.text = 'UnoCSS' - registerAutoComplete(context, ext) - registerAnnonations(cwd, context, status, ext) + registerAutoComplete(contextLoader, ext) + registerAnnonations(cwd, contextLoader, status, ext) + + ext.subscriptions.push(commands.registerCommand('unocss.reload', async () => { + await contextLoader.reload() + log.appendLine('[info] UnoCSS reloaded.') + })) } export function deactivate() {} diff --git a/packages/vscode/src/utils.ts b/packages/vscode/src/utils.ts index c245f512ae..2d21151018 100644 --- a/packages/vscode/src/utils.ts +++ b/packages/vscode/src/utils.ts @@ -1,3 +1,4 @@ +import path from 'path' import type { UnoGenerator } from '@unocss/core' import { cssIdRE } from '@unocss/core' import prettier from 'prettier/standalone' @@ -39,3 +40,8 @@ export async function getPrettiedMarkdown(uno: UnoGenerator, util: string) { export function isCssId(id: string) { return cssIdRE.test(id) } + +export function isSubdir(parent: string, child: string) { + const relative = path.relative(parent, child) + return relative && !relative.startsWith('..') && !path.isAbsolute(relative) +}