/
StatusBar.ts
329 lines (300 loc) · 12.6 KB
/
StatusBar.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import * as vscode from 'vscode'
import { type AuthStatus, type Configuration, isCodyIgnoredFile } from '@sourcegraph/cody-shared'
import { getConfiguration } from '../configuration'
import { getGhostHintEnablement } from '../commands/GhostHintDecorator'
import { FeedbackOptionItems, PremiumSupportItems } from './FeedbackOptions'
import { enableDebugMode } from './utils/export-logs'
interface StatusBarError {
title: string
description: string
errorType: StatusBarErrorName
onShow?: () => void
onSelect?: () => void
}
export interface CodyStatusBar {
dispose(): void
startLoading(
label: string,
params?: {
// When set, the loading lease will expire after the timeout to avoid getting stuck
timeoutMs: number
}
): () => void
addError(error: StatusBarError): () => void
hasError(error: StatusBarErrorName): boolean
syncAuthStatus(newStatus: AuthStatus): void
}
const DEFAULT_TEXT = '$(cody-logo-heavy)'
const DEFAULT_TOOLTIP = 'Cody Settings'
const QUICK_PICK_ITEM_CHECKED_PREFIX = '$(check) '
const QUICK_PICK_ITEM_EMPTY_INDENT_PREFIX = '\u00A0\u00A0\u00A0\u00A0\u00A0 '
const ONE_HOUR = 60 * 60 * 1000
type StatusBarErrorName = 'auth' | 'RateLimitError' | 'AutoCompleteDisabledByAdmin'
interface StatusBarItem extends vscode.QuickPickItem {
onSelect: () => Promise<void>
}
export function createStatusBar(): CodyStatusBar {
const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right)
statusBarItem.text = DEFAULT_TEXT
statusBarItem.tooltip = DEFAULT_TOOLTIP
statusBarItem.command = 'cody.status-bar.interacted'
statusBarItem.show()
let authStatus: AuthStatus | undefined
const command = vscode.commands.registerCommand(statusBarItem.command, async () => {
const workspaceConfig = vscode.workspace.getConfiguration()
const config = getConfiguration(workspaceConfig)
async function createFeatureToggle(
name: string,
description: string | undefined,
detail: string,
setting: string,
getValue: (config: Configuration) => boolean | Promise<boolean>,
requiresReload = false,
buttons: readonly vscode.QuickInputButton[] | undefined = undefined
): Promise<StatusBarItem> {
const isEnabled = await getValue(config)
return {
label:
(isEnabled ? QUICK_PICK_ITEM_CHECKED_PREFIX : QUICK_PICK_ITEM_EMPTY_INDENT_PREFIX) +
name,
description,
detail: QUICK_PICK_ITEM_EMPTY_INDENT_PREFIX + detail,
onSelect: async () => {
await workspaceConfig.update(setting, !isEnabled, vscode.ConfigurationTarget.Global)
const info = `${name} ${isEnabled ? 'disabled' : 'enabled'}.`
const response = await (requiresReload
? vscode.window.showInformationMessage(info, 'Reload Window')
: vscode.window.showInformationMessage(info))
if (response === 'Reload Window') {
await vscode.commands.executeCommand('workbench.action.reloadWindow')
}
},
buttons,
}
}
function createFeedbackAndSupportItems(): StatusBarItem[] {
const isPaidUser = authStatus?.isLoggedIn && !authStatus?.userCanUpgrade
const paidSupportItems = isPaidUser ? PremiumSupportItems : []
// Display to paid users (e.g. Enterprise users or Cody Pro uers) only
return [...paidSupportItems, ...FeedbackOptionItems]
}
if (errors.length > 0) {
errors.map(error => error.error.onShow?.())
}
const quickPick = vscode.window.createQuickPick()
quickPick.items = [
// These description should stay in sync with the settings in package.json
...(errors.length > 0
? [
{ label: 'notice', kind: vscode.QuickPickItemKind.Separator },
...errors.map(error => ({
label: `$(alert) ${error.error.title}`,
description: '',
detail: QUICK_PICK_ITEM_EMPTY_INDENT_PREFIX + error.error.description,
onSelect(): Promise<void> {
error.error.onSelect?.()
const index = errors.indexOf(error)
errors.splice(index)
rerender()
return Promise.resolve()
},
})),
]
: []),
{ label: 'enable/disable features', kind: vscode.QuickPickItemKind.Separator },
await createFeatureToggle(
'Code Autocomplete',
undefined,
'Enable Cody-powered code autocompletions',
'cody.autocomplete.enabled',
c => c.autocomplete,
false,
[
{
iconPath: new vscode.ThemeIcon('settings-more-action'),
tooltip: 'Autocomplete Settings',
onClick: () =>
vscode.commands.executeCommand('workbench.action.openSettings', {
query: '@ext:sourcegraph.cody-ai autocomplete',
}),
} as vscode.QuickInputButton,
]
),
await createFeatureToggle(
'Code Actions',
undefined,
'Enable Cody fix and explain options in the Quick Fix menu',
'cody.codeActions.enabled',
c => c.codeActions
),
await createFeatureToggle(
'Editor Title Icon',
undefined,
'Enable Cody to appear in editor title menu for quick access to Cody commands',
'cody.editorTitleCommandIcon',
c => c.editorTitleCommandIcon
),
await createFeatureToggle(
'Code Lenses',
undefined,
'Enable Code Lenses in documents for quick access to Cody commands',
'cody.commandCodeLenses',
c => c.commandCodeLenses
),
await createFeatureToggle(
'Command Hints',
undefined,
'Enable hints for Edit and Chat shortcuts, displayed alongside editor selections',
'cody.commandHints.enabled',
getGhostHintEnablement
),
await createFeatureToggle(
'Search Context',
'Beta',
'Enable using the natural language search index as an Enhanced Context chat source',
'cody.experimental.symfContext',
c => c.experimentalSymfContext,
false
),
{ label: 'settings', kind: vscode.QuickPickItemKind.Separator },
{
label: '$(gear) Cody Extension Settings',
async onSelect(): Promise<void> {
await vscode.commands.executeCommand('cody.settings.extension')
},
},
{
label: '$(symbol-namespace) Custom Commands Settings',
async onSelect(): Promise<void> {
await vscode.commands.executeCommand('cody.menu.commands-settings')
},
},
{ label: 'feedback & support', kind: vscode.QuickPickItemKind.Separator },
...createFeedbackAndSupportItems(),
]
quickPick.title = 'Cody Settings'
quickPick.placeholder = 'Choose an option'
quickPick.matchOnDescription = true
quickPick.show()
quickPick.onDidAccept(() => {
const option = quickPick.activeItems[0] as StatusBarItem
if (option && 'onSelect' in option) {
option.onSelect().catch(console.error)
}
quickPick.hide()
})
quickPick.onDidTriggerItemButton(item => {
// @ts-ignore: onClick is a custom extension to the QuickInputButton
item?.button?.onClick?.()
quickPick.hide()
})
// Debug Mode
quickPick.buttons = [
{
iconPath: new vscode.ThemeIcon('bug'),
tooltip: config.debugEnable ? 'Check Debug Logs' : 'Turn on Debug Mode',
onClick: () => enableDebugMode(),
} as vscode.QuickInputButton,
]
quickPick.onDidTriggerButton(async item => {
// @ts-ignore: onClick is a custom extension to the QuickInputButton
item?.onClick?.()
quickPick.hide()
})
})
// Reference counting to ensure loading states are handled consistently across different
// features
// TODO: Ensure the label is always set to the right value too.
let openLoadingLeases = 0
const errors: { error: StatusBarError; createdAt: number }[] = []
function rerender(): void {
if (openLoadingLeases > 0) {
statusBarItem.text = '$(loading~spin)'
} else {
statusBarItem.text = DEFAULT_TEXT
statusBarItem.tooltip = DEFAULT_TOOLTIP
}
if (errors.length > 0) {
statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground')
statusBarItem.tooltip = errors[0].error.title
} else {
statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.activeBackground')
}
}
// Clean up all errors after a certain time so they don't accumulate forever
function clearOutdatedErrors(): void {
const now = Date.now()
for (let i = errors.length - 1; i >= 0; i--) {
const error = errors[i]
if (now - error.createdAt >= ONE_HOUR) {
errors.splice(i, 1)
}
}
rerender()
}
// NOTE: Behind unstable feature flag and requires .cody/ignore enabled
// Listens for changes to the active text editor and updates the status bar text
// based on whether the active file is ignored by Cody or not.
// If ignored, adds 'Ignored' to the status bar text.
// Otherwise, rerenders the status bar.
const verifyActiveEditor = (uri?: vscode.Uri) => {
// NOTE: Non-file URIs are not supported by the .cody/ignore files and
// are ignored by default. As they are files that a user would not expect to
// be used by Cody, we will not display them with the "warning".
if (uri?.scheme === 'file' && isCodyIgnoredFile(uri)) {
statusBarItem.tooltip = 'Current file is ignored by Cody'
statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground')
} else {
rerender()
}
}
const onDocumentChange = vscode.window.onDidChangeActiveTextEditor(e => {
verifyActiveEditor(e?.document?.uri)
})
verifyActiveEditor(vscode.window.activeTextEditor?.document?.uri)
return {
startLoading(label: string, params: { timeoutMs?: number } = {}) {
openLoadingLeases++
statusBarItem.tooltip = label
rerender()
let didClose = false
const timeoutId = params.timeoutMs ? setTimeout(stopLoading, params.timeoutMs) : null
function stopLoading() {
if (didClose) {
return
}
didClose = true
openLoadingLeases--
rerender()
if (timeoutId) {
clearTimeout(timeoutId)
}
}
return stopLoading
},
addError(error: StatusBarError) {
const errorObject = { error, createdAt: Date.now() }
errors.push(errorObject)
setTimeout(clearOutdatedErrors, ONE_HOUR)
rerender()
return () => {
const index = errors.indexOf(errorObject)
if (index !== -1) {
errors.splice(index, 1)
rerender()
}
}
},
hasError(errorName: StatusBarErrorName): boolean {
return errors.some(e => e.error.errorType === errorName)
},
syncAuthStatus(newStatus: AuthStatus) {
authStatus = newStatus
},
dispose() {
statusBarItem.dispose()
command.dispose()
onDocumentChange.dispose()
},
}
}