From 55421162a727aec5916f9a2a3a5b1c7c13405aed Mon Sep 17 00:00:00 2001 From: Qiming Zhao Date: Mon, 24 Feb 2025 22:47:53 +0800 Subject: [PATCH] feat(fileSystemWatch): add more configurations Added: - fileSystemWatch.watchmanPath - fileSystemWatch.enable - fileSystemWatch.ignoredFolders --- data/schema.json | 22 ++++++++++ doc/coc-config.txt | 25 +++++++++++ doc/coc.txt | 22 ++++++++++ src/__tests__/core/fileSystemWatcher.test.ts | 36 ++++++---------- .../modules/extensionManager.test.ts | 5 ++- src/configuration/util.ts | 2 + src/core/documents.ts | 2 + src/core/fileSystemWatcher.ts | 42 +++++++++++++------ src/core/watchman.ts | 17 +------- src/extension/manager.ts | 13 +++--- src/types.ts | 6 +++ src/workspace.ts | 20 ++++++--- 12 files changed, 145 insertions(+), 67 deletions(-) diff --git a/data/schema.json b/data/schema.json index 1a924d40c43..6bd7b44a402 100644 --- a/data/schema.json +++ b/data/schema.json @@ -721,6 +721,7 @@ "coc.preferences.watchmanPath": { "type": "string", "scope": "application", + "deprecationMessage": "Use configuration \"fileSystemWatch.watchmanPath\" instead.", "description": "executable path for https://facebook.github.io/watchman/, detected from $PATH by default", "default": null }, @@ -1261,6 +1262,27 @@ "scope": "resource", "description": "Timeout for document highlight, in milliseconds." }, + "fileSystemWatch.watchmanPath": { + "type": ["null", "string"], + "scope": "application", + "description": "executable path for https://facebook.github.io/watchman/, detected from $PATH by default", + "default": null + }, + "fileSystemWatch.enable": { + "type": "boolean", + "default": true, + "scope": "application", + "description": "Enable file system watch support for workspace folders." + }, + "fileSystemWatch.ignoredFolders": { + "type": "array", + "default": ["/private/tmp", "/", "${tmpdir}"], + "scope": "application", + "description": "List of folders that should not be watched for file changes, environment variables and minimatch patterns can be used.", + "items": { + "type": "string" + } + }, "floatFactory.floatConfig": { "type": "object", "scope": "application", diff --git a/doc/coc-config.txt b/doc/coc-config.txt index 8f360bf0cfc..78cc47ebe6c 100644 --- a/doc/coc-config.txt +++ b/doc/coc-config.txt @@ -4,6 +4,7 @@ CONTENTS Core features Workspace |coc-config-workspace| + File system watch |coc-config-fileSystemWatch| Extensions |coc-config-extensions| Preferences |coc-config-preferences| Float factory |coc-config-floatFactory| @@ -103,6 +104,30 @@ WORKSPACE Scope: `application`, default: `true` +------------------------------------------------------------------------------ +FILESYSTEMWATCH *coc-config-fileSystemWatch* + +"fileSystemWatch.watchmanPath" *coc-config-filesystemwatch-watchmanPath* + + executable path for https://facebook.github.io/watchman/, detected + from $PATH by default + + Scope: `application`, default: `null` + +"fileSystemWatch.enable" *coc-config-filesystemwatch-enable* + + Enable file system watch support for workspace folders. + + Scope: `application`, default: `true` + +"fileSystemWatch.ignoredFolders" *coc-config-filesystemwatch-ignoredFolders* + + List of folders that should not be watched for file changes, + environment variables and minimatch patterns + can be used. + + Scope: `application`, default: `["/private/tmp", "/", "${tmpdir}"]` + ------------------------------------------------------------------------------ EXTENSIONS *coc-config-extensions* diff --git a/doc/coc.txt b/doc/coc.txt index 7d67a45f6fd..12c002cd761 100644 --- a/doc/coc.txt +++ b/doc/coc.txt @@ -8,6 +8,7 @@ CONTENTS *coc-contents* Introduction |coc-introduction| Requirements |coc-requirements| Installation |coc-installation| +File system watch |coc-filesystemwatch| Language server |coc-languageserver| Extensions |coc-extensions| Configuration |coc-configuration| @@ -146,6 +147,25 @@ To use Vim's native |packages| on Linux or macOS, use script like: > when using source code of coc.nvim, you'll have to run `npm install` in project root of coc.nvim. +============================================================================== +File system watch *coc-filesystemwatch* + +Watchman https://facebook.github.io/watchman/ is used by coc.nvim to provide +file change detection to extensions and languageservers. The watchman command +is detected from your $PATH, the feature will silently fail when watchman +can't work. + +Watchman automatically watch |coc-workspace-folders| for file events by +default. + +Use command: > + :CocCommand workspace.showOutput watchman +< +to open output channel of watchman. + +Use configuration |coc-config-fileSystemWatch| to change behavior of file +system watch. + ============================================================================== LANGUAGESERVER *coc-languageserver* @@ -674,6 +694,8 @@ Note most language servers only send diagnostics for opened buffers for performance reason, some lint tools could provide diagnostics for all files in workspace. +See |coc-highlights-diagnostics| for diagnostic related highlight groups. + *coc-diagnostics-refresh* Changes on diagnostics refresh ~ diff --git a/src/__tests__/core/fileSystemWatcher.test.ts b/src/__tests__/core/fileSystemWatcher.test.ts index b209897ffe0..ff8811461e5 100644 --- a/src/__tests__/core/fileSystemWatcher.test.ts +++ b/src/__tests__/core/fileSystemWatcher.test.ts @@ -6,8 +6,8 @@ import { v4 as uuid } from 'uuid' import { Disposable } from 'vscode-languageserver-protocol' import { URI } from 'vscode-uri' import Configurations from '../../configuration/index' -import { FileSystemWatcher, FileSystemWatcherManager } from '../../core/fileSystemWatcher' -import Watchman, { FileChangeItem, isValidWatchRoot } from '../../core/watchman' +import { FileSystemWatcher, FileSystemWatcherManager, should_ignore } from '../../core/fileSystemWatcher' +import Watchman, { FileChangeItem } from '../../core/watchman' import WorkspaceFolderController from '../../core/workspaceFolder' import RelativePattern from '../../model/relativePattern' import { GlobPattern } from '../../types' @@ -59,6 +59,7 @@ function sendSubscription(uid: string, root: string, files: FileChangeItem[]): v let capabilities: any let watchResponse: any +let defaultConfig = { watchmanPath: null, enable: true, ignoredFolders: [] } beforeAll(async () => { await helper.setup() }) @@ -67,7 +68,7 @@ beforeAll(done => { let userConfigFile = path.join(process.env.COC_VIMCONFIG, 'coc-settings.json') configurations = new Configurations(userConfigFile, undefined) workspaceFolder = new WorkspaceFolderController(configurations) - watcherManager = new FileSystemWatcherManager(workspaceFolder, 'watchman') + watcherManager = new FileSystemWatcherManager(workspaceFolder, defaultConfig) Object.assign(watcherManager, { disabled: false }) watcherManager.attach(helper.createNullChannel()) // create a mock sever for watchman @@ -235,21 +236,6 @@ describe('Watchman#createClient', () => { expect(client).toBeDefined() }) - it('should not create client for root', async () => { - await expect(async () => { - await Watchman.createClient(null, '/') - }).rejects.toThrow(Error) - }) -}) - -describe('isValidWatchRoot()', () => { - it('should check valid root', async () => { - expect(isValidWatchRoot('/')).toBe(false) - expect(isValidWatchRoot(os.homedir())).toBe(false) - expect(isValidWatchRoot(os.tmpdir())).toBe(false) - expect(isValidWatchRoot('/tmp/a')).toBe(true) - expect(isValidWatchRoot('/tmp/a/b/c')).toBe(true) - }) }) describe('fileSystemWatcher', () => { @@ -278,6 +264,11 @@ describe('fileSystemWatcher', () => { await watcherManager.waitClient(cwd) }) + it('should check ignored folders', async () => { + expect(should_ignore('/', [])).toBe(false) + expect(should_ignore('/', ['/', 'a'])).toBe(true) + }) + it('should use relative pattern #1', async () => { let folder = workspaceFolder.workspaceFolders[0] expect(folder).toBeDefined() @@ -428,7 +419,7 @@ describe('fileSystemWatcher', () => { watcher.onDidCreate(e => { uri = e }) - await helper.wait(50) + await waitReady(watcher) let changes: FileChangeItem[] = [createFileChange(`a`)] sendSubscription(watcher.subscribe, __dirname, changes) await helper.waitValue(() => { @@ -441,17 +432,16 @@ describe('create FileSystemWatcherManager', () => { it('should attach to existing workspace folder', async () => { let workspaceFolder = new WorkspaceFolderController(configurations) workspaceFolder.addWorkspaceFolder(cwd, false) - let watcherManager = new FileSystemWatcherManager(workspaceFolder, '') - Object.assign(watcherManager, { disabled: false }) + let watcherManager = new FileSystemWatcherManager(workspaceFolder, { ...defaultConfig, enable: false }) + watcherManager.disabled = false watcherManager.attach(helper.createNullChannel()) - await watcherManager.createClient(os.tmpdir()) await watcherManager.createClient(cwd) await watcherManager.waitClient(cwd) watcherManager.dispose() }) it('should get watchman path', async () => { - let watcherManager = new FileSystemWatcherManager(workspaceFolder, 'invalid_command') + let watcherManager = new FileSystemWatcherManager(workspaceFolder, { ...defaultConfig, watchmanPath: 'invalid_command' }) process.env.WATCHMAN_SOCK = '' await expect(async () => { await watcherManager.getWatchmanPath() diff --git a/src/__tests__/modules/extensionManager.test.ts b/src/__tests__/modules/extensionManager.test.ts index 72671618694..913d4361c69 100644 --- a/src/__tests__/modules/extensionManager.test.ts +++ b/src/__tests__/modules/extensionManager.test.ts @@ -644,8 +644,8 @@ describe('ExtensionManager', () => { let manager = create(tmpfolder) let res = await manager.loadExtension(extFolder) expect(res).toBe(true) - let spy = jest.spyOn(workspace, 'getWatchmanPath').mockImplementation(() => { - return '' + let spy = jest.spyOn(workspace.fileSystemWatchers, 'getWatchmanPath').mockImplementation(() => { + return Promise.reject('not found') }) let fn = async () => { await manager.watchExtension('name') @@ -659,6 +659,7 @@ describe('ExtensionManager', () => { it('should reload extension on file change', async () => { tmpfolder = createFolder() + workspace.fileSystemWatchers.disabled = false let extFolder = path.join(tmpfolder, 'node_modules', 'name') createExtension(extFolder, { name: 'name', main: 'entry.js', engines: { coc: '>=0.0.1' } }) let manager = create(tmpfolder) diff --git a/src/configuration/util.ts b/src/configuration/util.ts index cad92d8304f..e3c35918e4a 100644 --- a/src/configuration/util.ts +++ b/src/configuration/util.ts @@ -32,6 +32,8 @@ export function expand(input: string): string { return process.env[key] ?? match } switch (name) { + case 'tmpdir': + return os.tmpdir() case 'userHome': return os.homedir() case 'cwd': diff --git a/src/core/documents.ts b/src/core/documents.ts index 1b1d6721a9d..346d92291da 100644 --- a/src/core/documents.ts +++ b/src/core/documents.ts @@ -218,6 +218,8 @@ export default class Documents implements Disposable { return val } switch (name) { + case 'tmpdir': + return os.tmpdir() case 'userHome': return os.homedir() case 'workspace': diff --git a/src/core/fileSystemWatcher.ts b/src/core/fileSystemWatcher.ts index 44df2671f43..234da3953bd 100644 --- a/src/core/fileSystemWatcher.ts +++ b/src/core/fileSystemWatcher.ts @@ -2,34 +2,47 @@ import { WorkspaceFolder } from 'vscode-languageserver-types' import { URI } from 'vscode-uri' import { createLogger } from '../logger' -import { GlobPattern, IFileSystemWatcher, OutputChannel } from '../types' +import { FileWatchConfig, GlobPattern, IFileSystemWatcher, OutputChannel } from '../types' import { disposeAll } from '../util' import { splitArray } from '../util/array' import { isParentFolder, sameFile } from '../util/fs' -import { minimatch, path, which } from '../util/node' +import { minimatch, path, which, os } from '../util/node' import { Disposable, Emitter, Event } from '../util/protocol' import Watchman, { FileChange } from './watchman' import type WorkspaceFolderControl from './workspaceFolder' const logger = createLogger('fileSystemWatcher') +const WATCHMAN_COMMAND = 'watchman' export interface RenameEvent { oldUri: URI newUri: URI } +export function should_ignore(root: string, ignored: string[] | undefined): boolean { + for (let folder of ignored) { + if (sameFile(root, folder)) { + return true + } + } + return false +} + export class FileSystemWatcherManager { - private clientsMap: Map = new Map() + private clientsMap: Map = new Map() private disposables: Disposable[] = [] private channel: OutputChannel | undefined private creating: Set = new Set() public static watchers: Set = new Set() private readonly _onDidCreateClient = new Emitter() - private disabled = global.__TEST__ + public disabled = global.__TEST__ public readonly onDidCreateClient: Event = this._onDidCreateClient.event constructor( private workspaceFolder: WorkspaceFolderControl, - private watchmanPath: string | null + private config: FileWatchConfig ) { + if (!config.enable) { + this.disabled = true + } } public attach(channel: OutputChannel): void { @@ -56,23 +69,24 @@ export class FileSystemWatcherManager { }, null, this.disposables) } - public waitClient(root: string): Promise { - if (this.clientsMap.has(root)) return Promise.resolve() + public waitClient(root: string): Promise { + if (this.clientsMap.has(root)) return Promise.resolve(this.clientsMap.get(root)) return new Promise(resolve => { let disposable = this.onDidCreateClient(r => { if (r == root) { disposable.dispose() - resolve() + resolve(this.clientsMap.get(r)) } }) }) } - public async createClient(root: string): Promise { - if (this.watchmanPath == null || this.has(root) || this.disabled) return + public async createClient(root: string, skipCheck = false): Promise { + if (!skipCheck && (this.disabled || should_ignore(root, this.config.ignoredFolders))) return + if (this.has(root)) return this.waitClient(root) try { - let watchmanPath = await this.getWatchmanPath() this.creating.add(root) + let watchmanPath = await this.getWatchmanPath() let client = await Watchman.createClient(watchmanPath, root, this.channel) this.creating.delete(root) this.clientsMap.set(root, client) @@ -80,16 +94,18 @@ export class FileSystemWatcherManager { watcher.listen(root, client) } this._onDidCreateClient.fire(root) + return client } catch (e) { this.creating.delete(root) if (this.channel) this.channel.appendLine(`Error on create watchman client: ${e}`) + return false } } public async getWatchmanPath(): Promise { - let watchmanPath = this.watchmanPath + let watchmanPath = this.config.watchmanPath ?? WATCHMAN_COMMAND if (!process.env.WATCHMAN_SOCK) { - watchmanPath = await which(this.watchmanPath, { all: false }) + watchmanPath = await which(watchmanPath, { all: false }) } return watchmanPath } diff --git a/src/core/watchman.ts b/src/core/watchman.ts index 6eac0e925ec..b0b734d0e6b 100644 --- a/src/core/watchman.ts +++ b/src/core/watchman.ts @@ -3,8 +3,7 @@ import type { Client } from 'fb-watchman' import { v1 as uuidv1 } from 'uuid' import { createLogger } from '../logger' import { OutputChannel } from '../types' -import { isParentFolder } from '../util/fs' -import { minimatch, os, path } from '../util/node' +import { minimatch, path } from '../util/node' import { Disposable } from '../util/protocol' const logger = createLogger('core-watchman') const requiredCapabilities = ['relative_root', 'cmd-watch-project', 'wildmatch', 'field-new'] @@ -35,7 +34,6 @@ export type ChangeCallback = (FileChange) => void /** * Watchman wrapper for fb-watchman client - * * @public */ export default class Watchman { @@ -54,7 +52,7 @@ export default class Watchman { public checkCapability(): Promise { let { client } = this - return new Promise((resolve, reject) => { + return new Promise(resolve => { client.capabilityCheck({ optional: [], required: requiredCapabilities @@ -154,7 +152,6 @@ export default class Watchman { } public static async createClient(binaryPath: string, root: string, channel?: OutputChannel): Promise { - if (!isValidWatchRoot(root)) throw new Error(`Watch for ${root} is ignored`) let watchman: Watchman try { watchman = new Watchman(binaryPath, channel) @@ -169,13 +166,3 @@ export default class Watchman { } } } - -/** - * Exclude root, user's home, driver and tmpdir, but allow sub-directories under them. - */ -export function isValidWatchRoot(root: string): boolean { - if (root == '/' || root == '/tmp' || root == '/private/tmp' || root == os.tmpdir()) return false - if (isParentFolder(root, os.homedir(), true)) return false - if (path.parse(root).base == root) return false - return true -} diff --git a/src/extension/manager.ts b/src/extension/manager.ts index 807f8313ccd..93439dbc696 100644 --- a/src/extension/manager.ts +++ b/src/extension/manager.ts @@ -1,15 +1,14 @@ import { URI } from 'vscode-uri' import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../configuration/registry' import { ConfigurationScope } from '../configuration/types' -import Watchman from '../core/watchman' import events from '../events' import { createLogger } from '../logger' import Memos from '../model/memos' import { disposeAll, wait } from '../util' import { splitArray, toArray } from '../util/array' import { configHome, dataHome } from '../util/constants' -import { Extensions as ExtensionsInfo, getProperties, IExtensionRegistry, IStringDictionary } from '../util/extensionRegistry' -import { createExtension, ExtensionExport } from '../util/factory' +import { Extensions as ExtensionsInfo, IExtensionRegistry, IStringDictionary, getProperties } from '../util/extensionRegistry' +import { ExtensionExport, createExtension } from '../util/factory' import { isDirectory, loadJson, remove, statAsync, watchFile } from '../util/fs' import * as Is from '../util/is' import type { IJSONSchema } from '../util/jsonSchema' @@ -17,7 +16,7 @@ import { omit } from '../util/lodash' import { path } from '../util/node' import { deepClone, deepIterate, isEmpty } from '../util/object' import { Disposable, Emitter, Event } from '../util/protocol' -import { convertProperties, Registry } from '../util/registry' +import { Registry, convertProperties } from '../util/registry' import { createTiming } from '../util/timing' import window from '../window' import workspace from '../workspace' @@ -595,10 +594,8 @@ export class ExtensionManager { void window.showInformationMessage(`reloaded ${id}`) }, global.__TEST__ === true)) } else { - let watchmanPath = workspace.getWatchmanPath() - if (!watchmanPath) throw new Error('watchman not found') - let client: Watchman = await Watchman.createClient(watchmanPath, item.directory) - this.disposables.push(client) + let client = await workspace.fileSystemWatchers.createClient(item.directory, true) + if (!client) throw new Error('watchman not found') void window.showInformationMessage(`watching ${item.directory}`) await client.subscribe('**/*.js', async () => { await this.reloadExtension(id) diff --git a/src/types.ts b/src/types.ts index cf6566a5176..5be7cca776c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,12 @@ export interface AnsiHighlight { hlGroup: string } +export interface FileWatchConfig { + readonly watchmanPath: string | null | undefined + readonly enable: boolean + readonly ignoredFolders: string[] +} + export interface LocationWithTarget extends Location { /** * The full target range of this link. If the target for example is a symbol then target range is the diff --git a/src/workspace.ts b/src/workspace.ts index 61d9adf60d6..8d9d9bdaaa2 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -12,8 +12,8 @@ import channels from './core/channels' import ContentProvider from './core/contentProvider' import Documents from './core/documents' import Editors from './core/editors' -import Files, { FileCreateEvent, FileDeleteEvent, FileRenameEvent, FileWillCreateEvent, FileWillDeleteEvent, FileWillRenameEvent, TextDocumentWillSaveEvent } from './core/files' import { FileSystemWatcher, FileSystemWatcherManager } from './core/fileSystemWatcher' +import Files, { FileCreateEvent, FileDeleteEvent, FileRenameEvent, FileWillCreateEvent, FileWillDeleteEvent, FileWillRenameEvent, TextDocumentWillSaveEvent } from './core/files' import { callAsync, createNameSpace, findUp, getWatchmanPath, has, resolveModule, score } from './core/funcs' import Keymaps, { LocalMode, MapMode } from './core/keymaps' import * as ui from './core/ui' @@ -31,8 +31,8 @@ import { StrWidth } from './model/strwidth' import Task from './model/task' import { LinesTextDocument } from './model/textdocument' import { TextDocumentContentProvider } from './provider' -import { Autocmd, DidChangeTextDocumentParams, Env, GlobPattern, IConfigurationChangeEvent, KeymapOption, LocationWithTarget, QuickfixItem, TextDocumentMatch } from './types' -import { APIVERSION, dataHome, pluginRoot, userConfigFile, VERSION, watchmanCommand } from './util/constants' +import { Autocmd, DidChangeTextDocumentParams, Env, FileWatchConfig, GlobPattern, IConfigurationChangeEvent, KeymapOption, LocationWithTarget, QuickfixItem, TextDocumentMatch } from './types' +import { APIVERSION, VERSION, dataHome, pluginRoot, userConfigFile } from './util/constants' import { parseExtensionName } from './util/extensionRegistry' import { IJSONSchema } from './util/jsonSchema' import { path } from './util/node' @@ -115,9 +115,17 @@ export class Workspace { this.onWillCreateFiles = this.files.onWillCreateFiles this.onWillRenameFiles = this.files.onWillRenameFiles this.onWillDeleteFiles = this.files.onWillDeleteFiles - const preferences = configurations.initialConfiguration.get('coc.preferences') as any - const watchmanPath = preferences.watchmanPath ?? watchmanCommand - this.fileSystemWatchers = new FileSystemWatcherManager(this.workspaceFolderControl, watchmanPath) + // use global value only + let watchConfig = (configurations.initialConfiguration.inspect('fileSystemWatch').globalValue ?? {}) as Partial + let watchmanPath = watchConfig.watchmanPath ? watchConfig.watchmanPath : configurations.initialConfiguration.inspect('coc.preferences.watchmanPath').globalValue + if (typeof watchmanPath === 'string') watchmanPath = this.expand(watchmanPath) + const config: FileWatchConfig = { + watchmanPath, + enable: watchConfig.enable == null ? true : !!watchConfig.enable, + ignoredFolders: (Array.isArray(watchConfig.ignoredFolders) ? watchConfig.ignoredFolders.filter(s => typeof s === 'string') : ["${tmpdir}", "/private/tmp", "/"]).map(p => this.expand(p)) + } + + this.fileSystemWatchers = new FileSystemWatcherManager(this.workspaceFolderControl, config) } public get initialConfiguration(): WorkspaceConfiguration {