diff --git a/package.json b/package.json index 3ce450d2..ee84b606 100644 --- a/package.json +++ b/package.json @@ -309,7 +309,8 @@ }, "appMap.navie.useVSCodeLM": { "type": "boolean", - "description": "Use VSCode language model API for Navie AI if available.\nRequires a recent VSCode version and (currently) GitHub Copilot extension." + "description": "Use GitHub Copilot as Navie backend if available.\nRequires a recent VSCode version and GitHub Copilot extension.", + "default": true }, "appMap.navie.rpcPort": { "type": "number", @@ -705,7 +706,7 @@ "dependencies": { "@appland/appmap": "^3.129.0", "@appland/client": "^1.14.1", - "@appland/components": "^4.38.3", + "@appland/components": "^4.39.0", "@appland/diagrams": "^1.8.0", "@appland/models": "^2.10.2", "@appland/rpc": "^1.15.0", diff --git a/src/extension.ts b/src/extension.ts index ae91170e..7bf87c6f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -225,10 +225,13 @@ export async function activate(context: vscode.ExtensionContext): Promise = (async () => { await dependenciesInstalled; @@ -243,13 +246,14 @@ export async function activate(context: vscode.ExtensionContext): Promise { - if (e.affectsConfiguration('appMap.commandLineEnvironment')) rpcService.scheduleRestart(); + if (e.affectsConfiguration('appMap.commandLineEnvironment')) + rpcService.debouncedRestart(); }), vscode.commands.registerCommand('appmap.rpc.restart', async () => { await rpcService.restartServer(); vscode.window.showInformationMessage('Navie restarted successfully.'); }), - ChatCompletion.onSettingsChanged(rpcService.scheduleRestart, rpcService) + ChatCompletion.onSettingsChanged(rpcService.debouncedRestart, rpcService) ); const webview = ChatSearchWebview.register( diff --git a/src/lib/once.ts b/src/lib/once.ts new file mode 100644 index 00000000..d8734bff --- /dev/null +++ b/src/lib/once.ts @@ -0,0 +1,21 @@ +import * as vscode from 'vscode'; + +/** + * Returns true if the given key has not been shown before, and updates the global state accordingly. + * @param context + * @param key + */ +export default function once(context: vscode.ExtensionContext, key: string): boolean { + const hasBeenShown = context.globalState.get(key, false); + + if (!hasBeenShown) { + context.globalState.update(key, true); + return true; + } + + return false; +} + +once.reset = function (context: vscode.ExtensionContext, key: string) { + context.globalState.update(key, undefined); +}; diff --git a/src/services/chatCompletion.ts b/src/services/chatCompletion.ts index b0417a62..db519376 100644 --- a/src/services/chatCompletion.ts +++ b/src/services/chatCompletion.ts @@ -16,6 +16,7 @@ import vscode, { } from 'vscode'; import ExtensionSettings from '../configuration/extensionSettings'; +import once from '../lib/once'; const debug = debuglog('appmap-vscode:chat-completion'); @@ -29,7 +30,11 @@ let instance: Promise | undefined; export default class ChatCompletion implements Disposable { public readonly server: Server; - constructor(private portNumber = 0, public readonly key = randomKey()) { + constructor( + private portNumber = 0, + public readonly key = randomKey(), + public readonly host = '127.0.0.1' + ) { this.server = createServer(async (req, res) => { try { await this.handleRequest(req, res); @@ -44,7 +49,7 @@ export default class ChatCompletion implements Disposable { res.end(isNativeError(e) && e.message); } }); - this.server.listen(portNumber); + this.server.listen(portNumber, host); const listening = new Promise((resolve, reject) => this.server .on('listening', () => { @@ -57,7 +62,10 @@ export default class ChatCompletion implements Disposable { .on('error', reject) ); this.server.on('error', (e) => warn(`Chat completion server error: ${e}`)); - instance ??= listening; + if (!instance) { + instance = listening; + ChatCompletion.settingsChanged.fire(); + } } get ready(): Promise { @@ -71,13 +79,14 @@ export default class ChatCompletion implements Disposable { } get url(): string { - return `http://localhost:${this.port}/vscode/copilot`; + return `http://${this.host}:${this.port}/vscode/copilot`; } get env(): Record { const pref = ChatCompletion.preferredModel; + if (!pref) return {}; - const modelTokenLimit = pref?.maxInputTokens ?? 3926; + const modelTokenLimit = pref.maxInputTokens; const tokenLimitSetting = ExtensionSettings.navieContextTokenLimit; const tokenLimits = [modelTokenLimit, tokenLimitSetting].filter( (limit) => limit && limit > 0 @@ -86,7 +95,8 @@ export default class ChatCompletion implements Disposable { const env: Record = { OPENAI_API_KEY: this.key, OPENAI_BASE_URL: this.url, - APPMAP_NAVIE_MODEL: pref?.family ?? 'gpt-4o', + APPMAP_NAVIE_MODEL: pref.family, + APPMAP_NAVIE_COMPLETION_BACKEND: 'openai', }; if (tokenLimits.length) { @@ -102,16 +112,16 @@ export default class ChatCompletion implements Disposable { return ChatCompletion.models[0]; } - static async refreshModels(): Promise { + static async refreshModels(): Promise { const previousBest = this.preferredModel?.id; ChatCompletion.models = (await vscode.lm.selectChatModels()).sort( (a, b) => b.maxInputTokens - a.maxInputTokens + b.family.localeCompare(a.family) ); - if (this.preferredModel?.id !== previousBest) this.settingsChanged.fire(); + return this.preferredModel?.id !== previousBest; } - static get instance(): Promise { - if (!instance) return Promise.resolve(undefined); + static get instance(): Promise | undefined { + if (!instance) return undefined; return instance; } @@ -201,67 +211,112 @@ export default class ChatCompletion implements Disposable { } async dispose(): Promise { - if ((await instance) === this) instance = undefined; + if ((await instance) === this) { + instance = undefined; + ChatCompletion.settingsChanged.fire(); + } this.server.close(); } private static settingsChanged = new vscode.EventEmitter(); static onSettingsChanged = ChatCompletion.settingsChanged.event; - static initialize(context: ExtensionContext) { - // TODO: make the messages and handling generic for all LM extensions - const hasLM = 'lm' in vscode && 'selectChatModels' in vscode.lm; - - if (ExtensionSettings.useVsCodeLM && checkAvailability()) - context.subscriptions.push(new ChatCompletion()); + static async initialize(context: ExtensionContext) { + if (await this.checkConfiguration(context)) + context.subscriptions.push( + vscode.lm.onDidChangeChatModels(() => this.checkConfiguration(context)) + ); context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(async (e) => { - if (e.affectsConfiguration('appMap.navie.useVSCodeLM')) { - const instance = await ChatCompletion.instance; - if (!ExtensionSettings.useVsCodeLM && instance) await instance.dispose(); - else if (ExtensionSettings.useVsCodeLM && checkAvailability()) - context.subscriptions.push(new ChatCompletion()); - this.settingsChanged.fire(); - } - }) + vscode.workspace.onDidChangeConfiguration( + (e) => + e.affectsConfiguration('appMap.navie.useVSCodeLM') && + this.checkConfiguration(context, true) + ) ); + } - if (hasLM) { - ChatCompletion.refreshModels(); - vscode.lm.onDidChangeChatModels( - ChatCompletion.refreshModels, - undefined, - context.subscriptions - ); + static async checkConfiguration(context: ExtensionContext, switched = false): Promise { + // TODO: make the messages and handling generic for all LM extensions + const hasLM = 'lm' in vscode && 'selectChatModels' in vscode.lm; + const wantsLM = ExtensionSettings.useVsCodeLM; + + if (!hasLM) { + if (wantsLM) { + if (switched) + vscode.window.showErrorMessage( + 'AppMap: Copilot backend for Navie is enabled, but the LanguageModel API is not available.\nPlease update your VS Code to the latest version.' + ); + else if (once(context, 'no-lm-api-available')) + vscode.window.showInformationMessage( + 'AppMap: Navie can use Copilot, but the LanguageModel API is not available.\nPlease update your VS Code to the latest version if you want to use it.' + ); + } + return hasLM; + } + once.reset(context, 'no-lm-api-available'); + + if (!wantsLM) { + if (instance) { + await instance.then((i) => i.dispose()); + // must have been switched, so show message + vscode.window.showInformationMessage('AppMap: Copilot backend for Navie is disabled.'); + once.reset(context, 'chat-completion-ready'); + once.reset(context, 'chat-completion-no-models'); + } + return hasLM; } - function checkAvailability() { - if (!hasLM) - vscode.window.showErrorMessage( - 'AppMap: VS Code LM backend for Navie is enabled, but the LanguageModel API is not available.\nPlease update your VS Code to the latest version.' + // now it's hasLM and wantsLM + const changed = await this.refreshModels(); + if (this.preferredModel) { + if (!instance) { + context.subscriptions.push(new this()); + await this.instance; + } else if (changed) ChatCompletion.settingsChanged.fire(); + if (switched) + vscode.window.showInformationMessage( + `AppMap: Copilot backend for Navie is enabled, using model: ${this.preferredModel.name}` + ); + else if (once(context, 'chat-completion-ready')) + vscode.window.showInformationMessage( + `AppMap: Copilot backend for Navie is ready. Model: ${this.preferredModel.name}` ); - else if (!vscode.extensions.getExtension('github.copilot')) { + once.reset(context, 'chat-completion-no-models'); + } else { + if (instance) await instance.then((i) => i.dispose()); + if (switched) vscode.window .showErrorMessage( - 'AppMap: VS Code LM backend for Navie is enabled, but the GitHub Copilot extension is not installed.\nPlease install it from the marketplace and reload the window.', + 'AppMap: Copilot backend for Navie is enabled, but no compatible models were found.\nInstall Copilot to continue.', 'Install Copilot' ) - .then((selection) => { - if (selection === 'Install Copilot') { - const odc = vscode.lm.onDidChangeChatModels(() => { - context.subscriptions.push(new ChatCompletion()); - ChatCompletion.settingsChanged.fire(); - odc.dispose(); - }); + .then( + (selection) => + selection === 'Install Copilot' && vscode.commands.executeCommand( 'workbench.extensions.installExtension', 'github.copilot' - ); - } - }); - } else return true; + ) + ); + else if (once(context, 'chat-completion-no-models')) + vscode.window + .showInformationMessage( + 'AppMap: Navie can use Copilot, but no compatible models were found.\nYou can install Copilot to use this feature.', + 'Install Copilot' + ) + .then( + (selection) => + selection === 'Install Copilot' && + vscode.commands.executeCommand( + 'workbench.extensions.installExtension', + 'github.copilot' + ) + ); + once.reset(context, 'chat-completion-ready'); } + + return hasLM; } } diff --git a/src/services/navieConfigurationService.ts b/src/services/navieConfigurationService.ts index c3844312..a142ae9c 100644 --- a/src/services/navieConfigurationService.ts +++ b/src/services/navieConfigurationService.ts @@ -39,6 +39,15 @@ export default function navieConfigurationService(context: vscode.ExtensionConte ); } +export async function openAIApiKeyEquals( + extensionContext: vscode.ExtensionContext, + key: string | undefined +): Promise { + const { secrets } = extensionContext; + const storedKey = await secrets.get(OPENAI_API_KEY); + return key === storedKey; +} + export async function setOpenAIApiKey( extensionContext: vscode.ExtensionContext, key: string | undefined diff --git a/src/services/processWatcher.ts b/src/services/processWatcher.ts index 5446c6b7..8905e64d 100644 --- a/src/services/processWatcher.ts +++ b/src/services/processWatcher.ts @@ -76,6 +76,7 @@ export class ProcessWatcher implements vscode.Disposable { protected _onError: vscode.EventEmitter = new vscode.EventEmitter(); protected _onAbort: vscode.EventEmitter = new vscode.EventEmitter(); + protected _onBeforeRestart: vscode.EventEmitter = new vscode.EventEmitter(); protected shouldRun = false; protected hasAborted = false; @@ -101,6 +102,11 @@ export class ProcessWatcher implements vscode.Disposable { return this._onAbort.event; } + // Fired when the process is restarted. + public get onBeforeRestart(): vscode.Event { + return this._onBeforeRestart.event; + } + public get id(): ProcessId { return this.options.id; } @@ -169,6 +175,7 @@ export class ProcessWatcher implements vscode.Disposable { } async restart(): Promise { + this._onBeforeRestart.fire(); await this.stop(); await this.start(); } diff --git a/src/services/rpcProcessService.ts b/src/services/rpcProcessService.ts index 6d9f88b5..d03165e9 100644 --- a/src/services/rpcProcessService.ts +++ b/src/services/rpcProcessService.ts @@ -12,6 +12,7 @@ import assert from 'assert'; import { DEBUG_EXCEPTION, Telemetry } from '../telemetry'; import ErrorCode from '../telemetry/definitions/errorCodes'; import AssetService, { AssetIdentifier } from '../assets/assetService'; +import { openAIApiKeyEquals, setOpenAIApiKey } from './navieConfigurationService'; export type RpcConnect = (port: number) => Client; @@ -23,6 +24,14 @@ export interface RpcProcessServiceState { killProcess(): void; } +interface RpcSettings { + useCopilot?: boolean; + openAIApiKey?: string; + + // If an env var is set to undefined, it will be removed from the env. + env?: Record; +} + export default class RpcProcessService implements Disposable { public readonly _onRpcPortChange = new EventEmitter(); public readonly onRpcPortChange = this._onRpcPortChange.event; @@ -34,6 +43,7 @@ export default class RpcProcessService implements Disposable { private diposables: Disposable[] = []; private debounce?: NodeJS.Timeout; private restarting = false; + private restartTimeout?: NodeJS.Timeout; public constructor( private readonly context: ExtensionContext, @@ -72,6 +82,10 @@ export default class RpcProcessService implements Disposable { ); } + get onBeforeRestart(): vscode.Event { + return this.processWatcher.onBeforeRestart; + } + // Provides some internal state access, primarily for testing purposes. public get state(): RpcProcessServiceState { return { @@ -92,6 +106,7 @@ export default class RpcProcessService implements Disposable { return this.processWatcher.restart(); } + public restartDelay = 100; public debounceTime = 5000; public scheduleRestart() { @@ -102,13 +117,20 @@ export default class RpcProcessService implements Disposable { } } - private debouncedRestart(): void { + public debouncedRestart(): void { if (this.restarting) this.scheduleRestart(); - else { - this.debounce = undefined; - this.restarting = true; - this.restart().finally(() => (this.restarting = false)); - } + else if (!this.restartTimeout) { + // Add a small delay before triggering the restart to allow bulk configuration changes to + // propagate without triggering a longer debounce timeout. + this.restartTimeout = setTimeout(() => { + this.debounce = undefined; + this.restarting = true; + this.restartTimeout = undefined; + this.restart().finally(() => (this.restarting = false)); + }, this.restartDelay).unref(); + } /* else { + There's already a pending restart, so we don't need to do anything. + } */ } protected async pushConfiguration() { @@ -225,4 +247,34 @@ export default class RpcProcessService implements Disposable { this._onRpcPortChange.dispose(); this.diposables.forEach((d) => d.dispose()); } + + async updateSettings(settings: RpcSettings): Promise { + if (settings.useCopilot !== undefined) { + const config = vscode.workspace.getConfiguration('appMap.navie'); + const key = 'useVSCodeLM'; + + await config.update(key, settings.useCopilot, true); + + const otherSettings = config.inspect(key); + if (otherSettings?.workspaceValue !== undefined) { + await config.update(key, undefined, undefined); + } + } + + if (Object.hasOwnProperty.call(settings, 'openAIApiKey')) { + const sameKey = await openAIApiKeyEquals(this.context, settings.openAIApiKey); + if (!sameKey) { + await setOpenAIApiKey(this.context, settings.openAIApiKey); + this.debouncedRestart(); + } + } + + if (settings.env) { + const env = vscode.workspace.getConfiguration('appMap').get('commandLineEnvironment', {}); + Object.entries(settings.env).forEach(([k, v]) => { + env[k] = v; + }); + await vscode.workspace.getConfiguration('appMap').update('commandLineEnvironment', env, true); + } + } } diff --git a/src/util.ts b/src/util.ts index 4a63f9bf..00d12047 100644 --- a/src/util.ts +++ b/src/util.ts @@ -419,5 +419,7 @@ export async function parseLocation( ) ); } - return vscode.Uri.parse(location); + // Remove any file:// prefix from the URI. Uri.file() will add it back. Otherwise, it'll be interpreted as + // part of the path, perhaps a drive letter. + return vscode.Uri.file(location.replace(/file:\/\//g, '')); } diff --git a/src/webviews/chatSearchWebview.ts b/src/webviews/chatSearchWebview.ts index 3202f72e..a122e3fa 100644 --- a/src/webviews/chatSearchWebview.ts +++ b/src/webviews/chatSearchWebview.ts @@ -44,7 +44,8 @@ export default class ChatSearchWebview { private constructor( private readonly context: vscode.ExtensionContext, private readonly extensionState: ExtensionState, - private readonly dataService: ChatSearchDataService + private readonly dataService: ChatSearchDataService, + private readonly rpcService: RpcProcessService ) { this.filterStore = new FilterStore(context); this.filterStore.onDidChangeFilters((event) => { @@ -171,6 +172,16 @@ export default class ChatSearchWebview { type: 'update', mostRecentAppMaps: appmaps, }); + }), + this.rpcService.onRpcPortChange(() => { + panel.webview.postMessage({ + type: 'navie-restarted', + }); + }), + this.rpcService.onBeforeRestart(() => { + panel.webview.postMessage({ + type: 'navie-restarting', + }); }) ); @@ -248,12 +259,36 @@ export default class ChatSearchWebview { case 'select-llm-option': { const { option } = message; - if (option === 'default') { - await vscode.commands.executeCommand('appmap.clearNavieAiSettings'); - } else if (option === 'own-key') { - await vscode.commands.executeCommand('appmap.openAIApiKey.set'); - } else { - console.error(`Unknown option: ${option}`); + switch (option) { + case 'default': + this.rpcService.updateSettings({ + useCopilot: false, + openAIApiKey: '', + env: { + OPENAI_BASE_URL: undefined, + OPENAI_API_KEY: undefined, + AZURE_OPENAI_API_KEY: undefined, + ANTHROPIC_API_KEY: undefined, + }, + }); + break; + + case 'copilot': + this.rpcService.updateSettings({ useCopilot: true }); + break; + + case 'own-key': + this.rpcService.updateSettings({ + useCopilot: false, + openAIApiKey: await vscode.window.showInputBox({ placeHolder: 'OpenAI API Key' }), + env: { + OPENAI_BASE_URL: undefined, + }, + }); + break; + + default: + console.error(`Unknown option: ${option}`); } break; } @@ -304,6 +339,6 @@ export default class ChatSearchWebview { ): ChatSearchWebview { const dataService = new ChatSearchDataService(rpcService, appmaps); - return new ChatSearchWebview(context, extensionState, dataService); + return new ChatSearchWebview(context, extensionState, dataService, rpcService); } } diff --git a/test/unit/mock/vscode/workspace.ts b/test/unit/mock/vscode/workspace.ts index 6bcb2f92..dd7f4110 100644 --- a/test/unit/mock/vscode/workspace.ts +++ b/test/unit/mock/vscode/workspace.ts @@ -35,9 +35,42 @@ export const EVENTS = { onDidChangeConfiguration: listener(), }; +export class Configuration extends Map { + get(key: string, defaultValue?: unknown): unknown { + return super.get(key) ?? defaultValue; + } + + inspect(key: string): { workspaceValue?: unknown } | undefined { + return {}; + } + + update(key: string, value: unknown, target?: unknown): Promise { + if (value === undefined || value === null) { + this.delete(key); + } else { + let filteredValue = value; + if (typeof value === 'object') { + // Undefined/null values are deleted + filteredValue = Object.entries(value).reduce((acc, [k, v]) => { + if (v !== undefined && v !== null) acc[k] = v; + return acc; + }, {}); + } + this.set(key, filteredValue); + } + return Promise.resolve(); + } +} + +const configs = new Map(); + export default { fs, - getConfiguration: () => new Map(), + getConfiguration: (key: string) => { + let config = configs.get(key); + if (!config) configs.set(key, (config = new Configuration())); + return config; + }, workspaceFolders: [], onDidChangeConfiguration: EVENTS.onDidChangeConfiguration, onDidChangeWorkspaceFolders: EVENTS.onDidChangeWorkspaceFolders, diff --git a/test/unit/services/chatCompletion.test.ts b/test/unit/services/chatCompletion.test.ts index 224ecff3..d810d474 100644 --- a/test/unit/services/chatCompletion.test.ts +++ b/test/unit/services/chatCompletion.test.ts @@ -65,6 +65,7 @@ describe('ChatCompletion', () => { OPENAI_BASE_URL: chatCompletion.url, APPMAP_NAVIE_TOKEN_LIMIT: '325', APPMAP_NAVIE_MODEL: 'test-family', + APPMAP_NAVIE_COMPLETION_BACKEND: 'openai', }); }); @@ -124,7 +125,7 @@ describe('ChatCompletion', () => { expect(instance).to.equal(chatCompletion); expect(chatCompletion.port).to.be.above(0); - expect(chatCompletion.url).to.match(/^http:\/\/localhost:\d+\/vscode\/copilot$/); + expect(chatCompletion.url).to.match(/^http:\/\/127.0.0.1:\d+\/vscode\/copilot$/); // make an actual HTTP request to the server const res = await get(chatCompletion.url); diff --git a/test/unit/services/rpcProcessService.test.ts b/test/unit/services/rpcProcessService.test.ts index 745e9f86..cbe8706c 100644 --- a/test/unit/services/rpcProcessService.test.ts +++ b/test/unit/services/rpcProcessService.test.ts @@ -12,6 +12,8 @@ import RpcProcessService, { RpcProcessServiceState } from '../../../src/services import MockExtensionContext from '../../mocks/mockExtensionContext'; import EventEmitter from '../mock/vscode/EventEmitter'; import { waitFor } from '../../waitFor'; +import vscode from '../mock/vscode'; +import { Configuration } from '../mock/vscode/workspace'; chai.use(chaiAsPromised); @@ -189,4 +191,52 @@ describe('RpcProcessService', () => { await waitFor(`Expecting debounced restart`, () => restartSpy.calledOnce); }); }); + + describe('updateSettings', () => { + afterEach(() => { + sinon.restore(); + }); + + it('does nothing if no settings are changed', async () => { + const updateSpy = sinon.spy(Configuration.prototype, 'update'); + const restartSpy = sinon.spy(rpcService, 'debouncedRestart'); + await rpcService.updateSettings({}); + expect(restartSpy.called).to.be.false; + expect(updateSpy.called).to.be.false; + }); + + it('updates `useVSCodeLM` if the useCopilot setting is changed', async () => { + const updateSpy = sinon.spy(Configuration.prototype, 'update'); + await rpcService.updateSettings({ useCopilot: true }); + expect(updateSpy.calledWith('useVSCodeLM', true)).to.be.true; + }); + + it('restarts if the openAIApiKey setting is changed', async () => { + const restartSpy = sinon.spy(rpcService, 'debouncedRestart'); + await rpcService.updateSettings({ openAIApiKey: 'new-key' }); + expect(restartSpy.called).to.be.true; + }); + + it('does not restart if the openAIApiKey setting is the same', async () => { + extensionContext.secrets.store('openai.api_key', 'old-key'); + const restartSpy = sinon.spy(rpcService, 'debouncedRestart'); + await rpcService.updateSettings({ openAIApiKey: 'old-key' }); + expect(restartSpy.called).to.be.false; + }); + + it('updates `commandLineEnvironment` if the env setting is changed', async () => { + const updateSpy = sinon.spy(Configuration.prototype, 'update'); + await rpcService.updateSettings({ env: { foo: 'bar' } }); + expect(updateSpy.calledWith('commandLineEnvironment', { foo: 'bar' })).to.be.true; + }); + + it('properly sets env values', async () => { + vscode.workspace + .getConfiguration('appMap') + .update('commandLineEnvironment', { foo: 'bar', bar: 'baz' }, true); + await rpcService.updateSettings({ env: { foo: undefined, baz: 'qux' } }); + const env = vscode.workspace.getConfiguration('appMap').get('commandLineEnvironment'); + expect(env).to.deep.equal({ bar: 'baz', baz: 'qux' }); + }); + }); }); diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index 4bbc49b8..3d0187df 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -94,10 +94,7 @@ describe('parseLocation', () => { const result = (await parseLocation('C:\\path\\to\\file.rb')) as URI; expect(result instanceof URI).to.be.true; - - // TODO: This may have different behavior on Windows - // i.e. it may be a URI with a drive letter - expect(result.fsPath).to.equal('\\path\\to\\file.rb'); + expect(result.fsPath).to.equal('c:\\path\\to\\file.rb'); }); it('should parse a location with a line number', async () => { diff --git a/web/src/chatSearchView.js b/web/src/chatSearchView.js index 26686852..5a756489 100644 --- a/web/src/chatSearchView.js +++ b/web/src/chatSearchView.js @@ -84,6 +84,14 @@ export default function mountChatSearchView() { }); }); + messages + .on('navie-restarting', () => { + app.$refs.ui.onNavieRestarting(); + }) + .on('navie-restarted', () => { + app.$refs.ui.loadNavieConfig(); + }); + app.$on('open-install-instructions', () => { vscode.postMessage({ command: 'open-install-instructions' }); }); diff --git a/yarn.lock b/yarn.lock index c8162966..482960d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -182,9 +182,9 @@ __metadata: languageName: node linkType: hard -"@appland/components@npm:^4.38.3": - version: 4.38.3 - resolution: "@appland/components@npm:4.38.3" +"@appland/components@npm:^4.39.0": + version: 4.39.0 + resolution: "@appland/components@npm:4.39.0" dependencies: "@appland/client": ^1.12.0 "@appland/diagrams": ^1.7.0 @@ -210,7 +210,7 @@ __metadata: sql-formatter: ^4.0.2 vue: ^2.7.14 vuex: ^3.6.0 - checksum: 7b9f5529c75859a1a535cdcefa82c69b783fdfe629305d59bfc163c8fe6e36e6c52cb8bd20cf8561930a641b78c7f04ee43ed77e0e9fe511eabbc615037d7f7d + checksum: db2ce60dfa09e22356499a250d8b385fc2737361ce365be9c8a280023cf4d916ddd079b4bad259050b103ac91c4d774b10b634ccb19d52adab679050e8cde3c3 languageName: node linkType: hard @@ -3054,7 +3054,7 @@ __metadata: dependencies: "@appland/appmap": ^3.129.0 "@appland/client": ^1.14.1 - "@appland/components": ^4.38.3 + "@appland/components": ^4.39.0 "@appland/diagrams": ^1.8.0 "@appland/models": ^2.10.2 "@appland/rpc": ^1.15.0