|
| 1 | +import { readdir } from "fs/promises"; |
| 2 | +import vscode, { |
| 3 | + CancellationToken, |
| 4 | + OpenDialogOptions, |
| 5 | + Progress, |
| 6 | + QuickPick, |
| 7 | + QuickPickItem, |
| 8 | + QuickPickItemButtonEvent, |
| 9 | + QuickPickOptions, |
| 10 | +} from "vscode"; |
| 11 | +import logger from "./log/logger.js"; |
| 12 | +import { Result, ResultCode } from "./types.js"; |
| 13 | +import { createPromiseWithCancelAndTimeout } from "./utils.js"; |
| 14 | + |
| 15 | +interface QuickPickOptionsWithExternalLink extends QuickPickItem { |
| 16 | + externalLink?: string; |
| 17 | +} |
| 18 | + |
| 19 | +export interface ConfirmOptions< |
| 20 | + T extends QuickPickOptionsWithExternalLink, |
| 21 | + P extends QuickPickOptionsWithExternalLink, |
| 22 | +> { |
| 23 | + title?: string; |
| 24 | + placeholder?: string; |
| 25 | + yesQuickPickItem?: T; |
| 26 | + noQuickPickItem?: P; |
| 27 | +} |
| 28 | + |
| 29 | +export async function confirm< |
| 30 | + T extends QuickPickOptionsWithExternalLink, |
| 31 | + P extends QuickPickOptionsWithExternalLink, |
| 32 | +>(confirmOptions: ConfirmOptions<T, P>): Promise<boolean | undefined> { |
| 33 | + const setButtonForExternalLink = (item: QuickPickOptionsWithExternalLink) => { |
| 34 | + if (item.externalLink) { |
| 35 | + item.buttons = [ |
| 36 | + { |
| 37 | + iconPath: new vscode.ThemeIcon("link-external"), |
| 38 | + tooltip: `Open: ${item.externalLink}`, |
| 39 | + }, |
| 40 | + ]; |
| 41 | + } |
| 42 | + }; |
| 43 | + const yes: QuickPickOptionsWithExternalLink = confirmOptions.yesQuickPickItem ?? { label: "Yes" }; |
| 44 | + setButtonForExternalLink(yes); |
| 45 | + const no: QuickPickOptionsWithExternalLink = confirmOptions.noQuickPickItem ?? { label: "No" }; |
| 46 | + setButtonForExternalLink(no); |
| 47 | + const options: QuickPickOptions = { |
| 48 | + title: confirmOptions.title, |
| 49 | + placeHolder: confirmOptions.placeholder, |
| 50 | + canPickMany: false, |
| 51 | + ignoreFocusOut: true, |
| 52 | + }; |
| 53 | + const items = [yes, no]; |
| 54 | + const selected = await showQuickPickWithButtons(items, options, (_quickpick, event) => { |
| 55 | + if (event.item === yes && yes.externalLink) { |
| 56 | + vscode.env.openExternal(vscode.Uri.parse(yes.externalLink)); |
| 57 | + } else if (event.item === no && no.externalLink) { |
| 58 | + vscode.env.openExternal(vscode.Uri.parse(no.externalLink)); |
| 59 | + } else { |
| 60 | + logger.warning(`Unexpected QuickPickItemButtonEvent for item ${event.item.label}`); |
| 61 | + } |
| 62 | + }); |
| 63 | + return selected === undefined || selected.length === 0 ? undefined : selected[0] === yes; |
| 64 | +} |
| 65 | + |
| 66 | +export async function checkAndConfirmEmptyFolder( |
| 67 | + targetFolder: string, |
| 68 | + placeholder: string = "Selected folder is not empty. Do you want to continue?", |
| 69 | + title?: string, |
| 70 | +): Promise<boolean | undefined> { |
| 71 | + const files = await readdir(targetFolder); |
| 72 | + if (files.length === 0) { |
| 73 | + return true; |
| 74 | + } |
| 75 | + logger.info("Selected folder is not empty."); |
| 76 | + const confirmed = await confirm({ |
| 77 | + title: title, |
| 78 | + placeholder: placeholder, |
| 79 | + yesQuickPickItem: { |
| 80 | + label: "Yes", |
| 81 | + detail: `Selected folder: ${targetFolder}`, |
| 82 | + }, |
| 83 | + noQuickPickItem: { |
| 84 | + label: "No", |
| 85 | + }, |
| 86 | + }); |
| 87 | + if (confirmed === undefined) { |
| 88 | + logger.info("User cancelled the confirm QuickPick for non-empty folder."); |
| 89 | + return undefined; |
| 90 | + } else if (confirmed) { |
| 91 | + logger.info("User confirmed to continue with non empty folder."); |
| 92 | + return true; |
| 93 | + } else { |
| 94 | + logger.info("User confirmed not to continue with non empty folder."); |
| 95 | + return false; |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +export async function selectFolder( |
| 100 | + dlgTitle: string, |
| 101 | + btnLabel: string, |
| 102 | +): Promise<string | undefined> { |
| 103 | + logger.info(`Selecting folder for '${dlgTitle}'...`); |
| 104 | + const folderOptions: OpenDialogOptions = { |
| 105 | + canSelectMany: false, |
| 106 | + openLabel: btnLabel, |
| 107 | + canSelectFolders: true, |
| 108 | + canSelectFiles: false, |
| 109 | + title: dlgTitle, |
| 110 | + }; |
| 111 | + |
| 112 | + const folderUri = await vscode.window.showOpenDialog(folderOptions); |
| 113 | + if (!folderUri || folderUri.length === 0) { |
| 114 | + logger.info(`No folder selected for '${dlgTitle}'.`); |
| 115 | + return undefined; |
| 116 | + } |
| 117 | + const selectedFolder = folderUri[0].fsPath; |
| 118 | + logger.info(`Selected folder for '${dlgTitle}': ${selectedFolder}`); |
| 119 | + return selectedFolder; |
| 120 | +} |
| 121 | + |
| 122 | +/** |
| 123 | + * |
| 124 | + * @param dlgTitle |
| 125 | + * @param btnLabel |
| 126 | + * @param filters refer to {@link OpenDialogOptions.filters} . |
| 127 | + A set of file filters that are used by the dialog. Each entry is a human-readable label |
| 128 | + like "TypeScript", and an array of extensions, for example: |
| 129 | + ```ts |
| 130 | + { |
| 131 | + 'Images': ['png', 'jpg'], |
| 132 | + 'TypeScript': ['ts', 'tsx'] |
| 133 | + } |
| 134 | + ``` |
| 135 | + * @returns |
| 136 | + */ |
| 137 | +export async function selectFile( |
| 138 | + dlgTitle: string, |
| 139 | + btnLabel: string, |
| 140 | + filters: { [name: string]: string[] }, |
| 141 | +): Promise<string | undefined> { |
| 142 | + logger.info(`Selecting file for '${dlgTitle}' ...`); |
| 143 | + const fileOptions: OpenDialogOptions = { |
| 144 | + canSelectMany: false, |
| 145 | + openLabel: btnLabel, |
| 146 | + canSelectFolders: false, |
| 147 | + canSelectFiles: true, |
| 148 | + filters: filters, |
| 149 | + title: dlgTitle, |
| 150 | + }; |
| 151 | + |
| 152 | + const fileUri = await vscode.window.showOpenDialog(fileOptions); |
| 153 | + if (!fileUri || fileUri.length === 0) { |
| 154 | + logger.info(`No file selected for '${dlgTitle}'.`); |
| 155 | + return undefined; |
| 156 | + } |
| 157 | + const selectedFile = fileUri[0].fsPath; |
| 158 | + logger.info(`Selected file for '${dlgTitle}': ${selectedFile}`); |
| 159 | + return selectedFile; |
| 160 | +} |
| 161 | + |
| 162 | +interface ProgressOptions { |
| 163 | + title: string; |
| 164 | + withCancelAndTimeout: boolean; |
| 165 | + /** Only take effect when {@link ProgressOptions.withCancelAndTimeout} is true */ |
| 166 | + timeoutInMs: number; |
| 167 | +} |
| 168 | + |
| 169 | +export interface ExecuteWithUiOptions< |
| 170 | + T extends QuickPickOptionsWithExternalLink, |
| 171 | + P extends QuickPickOptionsWithExternalLink, |
| 172 | +> { |
| 173 | + /** |
| 174 | + * The name of the execution. Only used for logging now |
| 175 | + */ |
| 176 | + name: string; |
| 177 | + /** |
| 178 | + * Confirm options. No confirm step when undefined |
| 179 | + */ |
| 180 | + confirm?: ConfirmOptions<T, P>; |
| 181 | + /** |
| 182 | + * Progress options. No progress when undefined |
| 183 | + */ |
| 184 | + progress?: ProgressOptions; |
| 185 | +} |
| 186 | + |
| 187 | +export async function tryExecuteWithUi< |
| 188 | + T, |
| 189 | + P extends QuickPickOptionsWithExternalLink, |
| 190 | + Q extends QuickPickOptionsWithExternalLink, |
| 191 | +>( |
| 192 | + options: ExecuteWithUiOptions<P, Q>, |
| 193 | + func: ( |
| 194 | + progress: |
| 195 | + | Progress<{ |
| 196 | + message?: string; |
| 197 | + increment?: number; |
| 198 | + }> |
| 199 | + | undefined, |
| 200 | + token: CancellationToken | undefined, |
| 201 | + ) => Promise<T>, |
| 202 | +): Promise<Result<T>> { |
| 203 | + if (options.confirm) { |
| 204 | + const confirmed = await confirm(options.confirm); |
| 205 | + if (confirmed !== true) { |
| 206 | + logger.info(`User cancelled or declined the confirmation for '${options.name}'.`); |
| 207 | + return { code: ResultCode.Cancelled }; |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + if (options.progress) { |
| 212 | + const po = options.progress; |
| 213 | + return await vscode.window.withProgress( |
| 214 | + { |
| 215 | + location: vscode.ProgressLocation.Notification, |
| 216 | + cancellable: true, |
| 217 | + title: options.progress.title, |
| 218 | + }, |
| 219 | + async (progress, token) => { |
| 220 | + try { |
| 221 | + const r = |
| 222 | + po.withCancelAndTimeout === true |
| 223 | + ? await createPromiseWithCancelAndTimeout( |
| 224 | + func(progress, token), |
| 225 | + token, |
| 226 | + po.timeoutInMs, |
| 227 | + ) |
| 228 | + : await func(progress, token); |
| 229 | + return { code: ResultCode.Success, value: r }; |
| 230 | + } catch (e: any) { |
| 231 | + if (e === ResultCode.Cancelled) { |
| 232 | + logger.info(`User cancelled the progress: "${options.name}"`); |
| 233 | + return { code: ResultCode.Cancelled }; |
| 234 | + } else if (e === ResultCode.Timeout) { |
| 235 | + logger.error(`Progress "${options.name}" timeout after ${po.timeoutInMs}ms`, [e]); |
| 236 | + return { code: ResultCode.Timeout }; |
| 237 | + } else { |
| 238 | + logger.error(`Unexpected error in the progress of "${options.name}"`, [e]); |
| 239 | + return { code: ResultCode.Fail, details: e }; |
| 240 | + } |
| 241 | + } |
| 242 | + }, |
| 243 | + ); |
| 244 | + } else { |
| 245 | + try { |
| 246 | + const r = await func(undefined, undefined); |
| 247 | + return { code: ResultCode.Success, value: r }; |
| 248 | + } catch (e) { |
| 249 | + logger.error(`Unexpected error for ${options.name}`, [e]); |
| 250 | + return { code: ResultCode.Fail, details: e }; |
| 251 | + } |
| 252 | + } |
| 253 | +} |
| 254 | + |
| 255 | +export async function showQuickPickWithButtons<T extends QuickPickItem>( |
| 256 | + items: T[], |
| 257 | + options: QuickPickOptions, |
| 258 | + onItemButtonTriggered: (quickpick: QuickPick<T>, item: QuickPickItemButtonEvent<T>) => void, |
| 259 | +) { |
| 260 | + const quickPickup = vscode.window.createQuickPick<T>(); |
| 261 | + quickPickup.items = items; |
| 262 | + if (options.title) quickPickup.title = options.title; |
| 263 | + if (options.placeHolder) quickPickup.placeholder = options.placeHolder; |
| 264 | + if (options.canPickMany) quickPickup.canSelectMany = options.canPickMany; |
| 265 | + if (options.ignoreFocusOut) quickPickup.ignoreFocusOut = options.ignoreFocusOut; |
| 266 | + if (options.matchOnDescription) quickPickup.matchOnDescription = options.matchOnDescription; |
| 267 | + if (options.matchOnDetail) quickPickup.matchOnDetail = options.matchOnDetail; |
| 268 | + quickPickup.onDidTriggerItemButton((event) => { |
| 269 | + onItemButtonTriggered(quickPickup, event); |
| 270 | + }); |
| 271 | + const selectionPromise = new Promise<T[] | undefined>((resolve) => { |
| 272 | + quickPickup.onDidAccept(() => { |
| 273 | + const selectedItem = [...quickPickup.selectedItems]; |
| 274 | + resolve(selectedItem); |
| 275 | + quickPickup.hide(); |
| 276 | + }); |
| 277 | + quickPickup.onDidHide(() => { |
| 278 | + resolve(undefined); |
| 279 | + quickPickup.dispose(); |
| 280 | + }); |
| 281 | + }); |
| 282 | + quickPickup.show(); |
| 283 | + |
| 284 | + return selectionPromise; |
| 285 | +} |
0 commit comments