diff --git a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx index eeb25f737..36defc016 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx +++ b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx @@ -12,6 +12,7 @@ import { Sketch, LibraryService, ArduinoDaemon, + SketchesError, } from '../common/protocol'; import { Mutex } from 'async-mutex'; import { @@ -20,6 +21,7 @@ import { MenuModelRegistry, ILogger, DisposableCollection, + ApplicationError, } from '@theia/core'; import { Dialog, @@ -76,6 +78,8 @@ import { IDEUpdaterDialog } from './dialogs/ide-updater/ide-updater-dialog'; import { IDEUpdater } from '../common/protocol/ide-updater'; import { FileSystemFrontendContribution } from '@theia/filesystem/lib/browser/filesystem-frontend-contribution'; import { HostedPluginEvents } from './hosted-plugin-events'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { Notifications } from './contributions/notifications'; const INIT_LIBS_AND_PACKAGES = 'initializedLibsAndPackages'; export const SKIP_IDE_VERSION = 'skipIDEVersion'; @@ -155,6 +159,9 @@ export class ArduinoFrontendContribution @inject(ArduinoDaemon) private readonly daemon: ArduinoDaemon; + @inject(WorkspaceService) + private readonly workspaceService: WorkspaceService; + protected invalidConfigPopup: | Promise | undefined; @@ -540,13 +547,55 @@ export class ArduinoFrontendContribution } }); } - } catch (e) { - console.error(e); - const message = e instanceof Error ? e.message : JSON.stringify(e); - this.messageService.error(message); + } catch (err) { + if (SketchesError.NotFound.is(err)) { + this.openFallbackSketch(err); + } else { + console.error(err); + const message = + err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : String(err); + this.messageService.error(message); + } } } + private openFallbackSketch( + err: ApplicationError< + number, + { + uri: string; + } + > + ) { + this.sketchService.createNewSketch().then((sketch) => { + this.workspaceService.open( + new URI(sketch.uri), + Object.assign( + { + preserveWindow: true, + }, + { + tasks: [ + { + command: Notifications.Commands.NOTIFY.id, + args: [ + { + type: 'error', + message: err.message, + }, + ], + }, + ], + } + ) + ); + }); + } + protected async ensureOpened( uri: string, forceOpen = false, diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index 44fde3c4d..2f5c23bad 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -301,9 +301,10 @@ import { CoreErrorHandler } from './contributions/core-error-handler'; import { CompilerErrors } from './contributions/compiler-errors'; import { WidgetManager } from './theia/core/widget-manager'; import { WidgetManager as TheiaWidgetManager } from '@theia/core/lib/browser/widget-manager'; -import { StartupTask } from './widgets/sketchbook/startup-task'; +import { StartupTasks } from './widgets/sketchbook/startup-task'; import { IndexesUpdateProgress } from './contributions/indexes-update-progress'; import { Daemon } from './contributions/daemon'; +import { Notifications } from './contributions/notifications'; MonacoThemingService.register({ id: 'arduino-theme', @@ -696,9 +697,11 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, PlotterFrontendContribution); Contribution.configure(bind, Format); Contribution.configure(bind, CompilerErrors); - Contribution.configure(bind, StartupTask); + Contribution.configure(bind, StartupTasks); Contribution.configure(bind, IndexesUpdateProgress); Contribution.configure(bind, Daemon); + Contribution.configure(bind, StartupTasks); + Contribution.configure(bind, Notifications); // Disabled the quick-pick customization from Theia when multiple formatters are available. // Use the default VS Code behavior, and pick the first one. In the IDE2, clang-format has `exclusive` selectors. diff --git a/arduino-ide-extension/src/browser/boards/boards-service-provider.ts b/arduino-ide-extension/src/browser/boards/boards-service-provider.ts index b7db7b4c6..a65ed3abc 100644 --- a/arduino-ide-extension/src/browser/boards/boards-service-provider.ts +++ b/arduino-ide-extension/src/browser/boards/boards-service-provider.ts @@ -21,6 +21,7 @@ import { ArduinoCommands } from '../arduino-commands'; import { StorageWrapper } from '../storage-wrapper'; import { nls } from '@theia/core/lib/common'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; @injectable() export class BoardsServiceProvider implements FrontendApplicationContribution { @@ -39,6 +40,9 @@ export class BoardsServiceProvider implements FrontendApplicationContribution { @inject(NotificationCenter) protected notificationCenter: NotificationCenter; + @inject(FrontendApplicationStateService) + private readonly appStateService: FrontendApplicationStateService; + protected readonly onBoardsConfigChangedEmitter = new Emitter(); protected readonly onAvailableBoardsChangedEmitter = new Emitter< @@ -87,11 +91,12 @@ export class BoardsServiceProvider implements FrontendApplicationContribution { this.notifyPlatformUninstalled.bind(this) ); - Promise.all([ - this.boardsService.getAttachedBoards(), - this.boardsService.getAvailablePorts(), - this.loadState(), - ]).then(async ([attachedBoards, availablePorts]) => { + this.appStateService.reachedState('ready').then(async () => { + const [attachedBoards, availablePorts] = await Promise.all([ + this.boardsService.getAttachedBoards(), + this.boardsService.getAvailablePorts(), + this.loadState(), + ]); this._attachedBoards = attachedBoards; this._availablePorts = availablePorts; this.onAvailablePortsChangedEmitter.fire(this._availablePorts); diff --git a/arduino-ide-extension/src/browser/contributions/notifications.ts b/arduino-ide-extension/src/browser/contributions/notifications.ts new file mode 100644 index 000000000..70ed6c50e --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/notifications.ts @@ -0,0 +1,52 @@ +import { injectable } from '@theia/core/shared/inversify'; +import { Command, CommandRegistry, Contribution } from './contribution'; + +@injectable() +export class Notifications extends Contribution { + override registerCommands(registry: CommandRegistry): void { + registry.registerCommand(Notifications.Commands.NOTIFY, { + execute: (arg) => { + if (NotifyParams.is(arg)) { + switch (arg.type) { + case 'info': + return this.messageService.info(arg.message); + case 'warn': + return this.messageService.warn(arg.message); + case 'error': + return this.messageService.error(arg.message); + } + } + }, + }); + } +} +export namespace Notifications { + export namespace Commands { + export const NOTIFY: Command = { + id: 'arduino-notify', + }; + } +} +const TypeLiterals = ['info', 'warn', 'error'] as const; +export type Type = typeof TypeLiterals[number]; +interface NotifyParams { + readonly type: Type; + readonly message: string; +} +namespace NotifyParams { + export function is(arg: unknown): arg is NotifyParams { + if (typeof arg === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const object = arg as any; + return ( + 'message' in object && + 'type' in object && + typeof object['message'] === 'string' && + typeof object['type'] === 'string' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TypeLiterals.includes(object['type'] as any) + ); + } + return false; + } +} diff --git a/arduino-ide-extension/src/browser/contributions/sketchbook.ts b/arduino-ide-extension/src/browser/contributions/sketchbook.ts index 80dc99065..fdac918b7 100644 --- a/arduino-ide-extension/src/browser/contributions/sketchbook.ts +++ b/arduino-ide-extension/src/browser/contributions/sketchbook.ts @@ -5,7 +5,11 @@ import { ArduinoMenus } from '../menu/arduino-menus'; import { MainMenuManager } from '../../common/main-menu-manager'; import { NotificationCenter } from '../notification-center'; import { Examples } from './examples'; -import { SketchContainer } from '../../common/protocol'; +import { + SketchContainer, + SketchesError, + SketchRef, +} from '../../common/protocol'; import { OpenSketch } from './open-sketch'; import { nls } from '@theia/core/lib/common'; @@ -24,15 +28,14 @@ export class Sketchbook extends Examples { protected readonly notificationCenter: NotificationCenter; override onStart(): void { - this.sketchServiceClient.onSketchbookDidChange(() => { - this.sketchService.getSketches({}).then((container) => { - this.register(container); - this.mainMenuManager.update(); - }); - }); + this.sketchServiceClient.onSketchbookDidChange(() => this.update()); } override async onReady(): Promise { + this.update(); + } + + private update() { this.sketchService.getSketches({}).then((container) => { this.register(container); this.mainMenuManager.update(); @@ -59,11 +62,24 @@ export class Sketchbook extends Examples { protected override createHandler(uri: string): CommandHandler { return { execute: async () => { - const sketch = await this.sketchService.loadSketch(uri); - return this.commandService.executeCommand( - OpenSketch.Commands.OPEN_SKETCH.id, - sketch - ); + let sketch: SketchRef | undefined = undefined; + try { + sketch = await this.sketchService.loadSketch(uri); + } catch (err) { + if (SketchesError.NotFound.is(err)) { + // To handle the following: + // Open IDE2, delete a sketch from sketchbook, click on File > Sketchbook > the deleted sketch. + // Filesystem watcher misses out delete events on macOS; hence IDE2 has no chance to update the menu items. + this.messageService.error(err.message); + this.update(); + } + } + if (sketch) { + await this.commandService.executeCommand( + OpenSketch.Commands.OPEN_SKETCH.id, + sketch + ); + } }, }; } diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts index 3ba955c7c..730c71201 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts @@ -17,11 +17,16 @@ import { ConfigService } from '../../../common/protocol/config-service'; import { SketchesService, Sketch, + SketchesError, } from '../../../common/protocol/sketches-service'; import { BoardsServiceProvider } from '../../boards/boards-service-provider'; import { BoardsConfig } from '../../boards/boards-config'; import { FileStat } from '@theia/filesystem/lib/common/files'; -import { StartupTask } from '../../widgets/sketchbook/startup-task'; +import { + StartupTask, + StartupTasks, +} from '../../widgets/sketchbook/startup-task'; +import { setURL } from '../../utils/window'; @injectable() export class WorkspaceService extends TheiaWorkspaceService { @@ -60,6 +65,35 @@ export class WorkspaceService extends TheiaWorkspaceService { this.onCurrentWidgetChange({ newValue, oldValue: null }); } + protected override async toFileStat( + uri: string | URI | undefined + ): Promise { + const stat = await super.toFileStat(uri); + if (!stat) { + return this.toFileStatWithNewSketchFallback(uri); + } + return stat; + } + + private async toFileStatWithNewSketchFallback( + uri: string | URI | undefined + ): Promise { + if (!uri) { + return; + } + try { + await this.sketchService.loadSketch( + uri instanceof URI ? uri.toString() : uri + ); + } catch (err) { + if (SketchesError.NotFound.is(err)) { + this.messageService.error(err.message); + } + } + const newSketchUri = await this.sketchService.createNewSketch(); + return this.toFileStat(newSketchUri.uri); + } + // Was copied from the Theia implementation. // Unlike the default behavior, IDE2 does not check the existence of the workspace before open. protected override async doGetDefaultWorkspaceUri(): Promise< @@ -78,6 +112,7 @@ export class WorkspaceService extends TheiaWorkspaceService { const wpPath = decodeURI(window.location.hash.substring(1)); const workspaceUri = new URI().withPath(wpPath).withScheme('file'); // ### Customization! Here, we do no check if the workspace exists. + // ### The error or missing sketch handling is done in the customized `toFileStat`. return workspaceUri.toString(); } else { // Else, ask the server for its suggested workspace (usually the one @@ -127,7 +162,7 @@ export class WorkspaceService extends TheiaWorkspaceService { protected override openWindow(uri: FileStat, options?: WorkspaceInput): void { const workspacePath = uri.resource.path.toString(); if (this.shouldPreserveWindow(options)) { - this.reloadWindow(); + this.reloadWindow(options); // Unlike Theia, IDE2 passes the `input` downstream. } else { try { this.openNewWindow(workspacePath, options); // Unlike Theia, IDE2 passes the `input` downstream. @@ -139,21 +174,25 @@ export class WorkspaceService extends TheiaWorkspaceService { } } + protected override reloadWindow(options?: WorkspaceInput): void { + if (StartupTasks.WorkspaceInput.is(options)) { + setURL(StartupTask.append(options.tasks, new URL(window.location.href))); + } + super.reloadWindow(); + } + protected override openNewWindow( workspacePath: string, options?: WorkspaceInput ): void { const { boardsConfig } = this.boardsServiceProvider; - const url = BoardsConfig.Config.setConfig( + let url = BoardsConfig.Config.setConfig( boardsConfig, new URL(window.location.href) ); // Set the current boards config for the new browser window. url.hash = workspacePath; - if (StartupTask.WorkspaceInput.is(options)) { - url.searchParams.set( - StartupTask.QUERY_STRING, - encodeURIComponent(JSON.stringify(options.tasks)) - ); + if (StartupTasks.WorkspaceInput.is(options)) { + url = StartupTask.append(options.tasks, url); } this.windowService.openNewWindow(url.toString()); diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-variable-contribution.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-variable-contribution.ts index 1ca0b7b85..2f9c88800 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-variable-contribution.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-variable-contribution.ts @@ -10,21 +10,31 @@ import { CurrentSketch, SketchesServiceClientImpl, } from '../../../common/protocol/sketches-service-client-impl'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; @injectable() export class WorkspaceVariableContribution extends TheiaWorkspaceVariableContribution { @inject(SketchesServiceClientImpl) - protected readonly sketchesServiceClient: SketchesServiceClientImpl; + private readonly sketchesServiceClient: SketchesServiceClientImpl; - protected currentSketch?: Sketch; + private currentSketch?: Sketch; @postConstruct() protected override init(): void { - this.sketchesServiceClient.currentSketch().then((sketch) => { - if (CurrentSketch.isValid(sketch)) { - this.currentSketch = sketch; - } - }); + const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); + if (CurrentSketch.isValid(sketch)) { + this.currentSketch = sketch; + } else { + const toDispose = new DisposableCollection(); + toDispose.push( + this.sketchesServiceClient.onCurrentSketchDidChange((sketch) => { + if (CurrentSketch.isValid(sketch)) { + this.currentSketch = sketch; + } + toDispose.dispose(); + }) + ); + } } override getResourceUri(): URI | undefined { diff --git a/arduino-ide-extension/src/browser/utils/window.ts b/arduino-ide-extension/src/browser/utils/window.ts new file mode 100644 index 000000000..54e046724 --- /dev/null +++ b/arduino-ide-extension/src/browser/utils/window.ts @@ -0,0 +1,7 @@ +/** + * Changes the `window.location` without navigating away. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function setURL(url: URL, data: any = {}): void { + history.pushState(data, '', url); +} diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/startup-task.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/startup-task.ts index 47ab60dbc..25cf33a3e 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/startup-task.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/startup-task.ts @@ -1,36 +1,86 @@ import { injectable } from '@theia/core/shared/inversify'; import { WorkspaceInput as TheiaWorkspaceInput } from '@theia/workspace/lib/browser'; import { Contribution } from '../../contributions/contribution'; +import { setURL } from '../../utils/window'; -export interface Task { +@injectable() +export class StartupTasks extends Contribution { + override onReady(): void { + const tasks = StartupTask.get(new URL(window.location.href)); + console.log(`Executing startup tasks: ${JSON.stringify(tasks)}`); + tasks.forEach(({ command, args = [] }) => + this.commandService + .executeCommand(command, ...args) + .catch((err) => + console.error( + `Error occurred when executing the startup task '${command}'${ + args?.length ? ` with args: '${JSON.stringify(args)}` : '' + }.`, + err + ) + ) + ); + if (tasks.length) { + // Remove the startup tasks after the execution. + // Otherwise, IDE2 executes them again on a window reload event. + setURL(StartupTask.set([], new URL(window.location.href))); + console.info(`Removed startup tasks from URL.`); + } + } +} + +export interface StartupTask { command: string; /** - * This must be JSON serializable. + * Must be JSON serializable. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any args?: any[]; } - -@injectable() -export class StartupTask extends Contribution { - override onReady(): void { - const params = new URLSearchParams(window.location.search); - const encoded = params.get(StartupTask.QUERY_STRING); - if (!encoded) return; - - const commands = JSON.parse(decodeURIComponent(encoded)); - - if (Array.isArray(commands)) { - commands.forEach(({ command, args }) => { - this.commandService.executeCommand(command, ...args); - }); +export namespace StartupTask { + const QUERY = 'startupTasks'; + export function is(arg: unknown): arg is StartupTasks { + if (typeof arg === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const object = arg as any; + return 'command' in object && typeof object['command'] === 'string'; + } + return false; + } + export function get(url: URL): StartupTask[] { + const { searchParams } = url; + const encodedTasks = searchParams.get(QUERY); + if (encodedTasks) { + const rawTasks = decodeURIComponent(encodedTasks); + const tasks = JSON.parse(rawTasks); + if (Array.isArray(tasks)) { + return tasks.filter((task) => { + if (StartupTask.is(task)) { + return true; + } + console.warn(`Was not a task: ${JSON.stringify(task)}. Ignoring.`); + return false; + }); + } else { + debugger; + console.warn(`Startup tasks was not an array: ${rawTasks}. Ignoring.`); + } } + return []; + } + export function set(tasks: StartupTask[], url: URL): URL { + const copy = new URL(url); + copy.searchParams.set(QUERY, encodeURIComponent(JSON.stringify(tasks))); + return copy; + } + export function append(tasks: StartupTask[], url: URL): URL { + return set([...get(url), ...tasks], url); } } -export namespace StartupTask { - export const QUERY_STRING = 'startupTasks'; + +export namespace StartupTasks { export interface WorkspaceInput extends TheiaWorkspaceInput { - tasks: Task[]; + tasks: StartupTask[]; } export namespace WorkspaceInput { export function is( diff --git a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts index 1e50729d6..58ac383cb 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts @@ -47,7 +47,6 @@ export class SketchesServiceClientImpl @inject(ConfigService) protected readonly configService: ConfigService; - protected toDispose = new DisposableCollection(); protected sketches = new Map(); // TODO: rename this + event to the `onBlabla` pattern protected sketchbookDidChangeEmitter = new Emitter<{ @@ -55,8 +54,16 @@ export class SketchesServiceClientImpl removed: SketchRef[]; }>(); readonly onSketchbookDidChange = this.sketchbookDidChangeEmitter.event; + protected currentSketchDidChangeEmitter = new Emitter(); + readonly onCurrentSketchDidChange = this.currentSketchDidChangeEmitter.event; - private _currentSketch = new Deferred(); + protected toDispose = new DisposableCollection( + this.sketchbookDidChangeEmitter, + this.currentSketchDidChangeEmitter + ); + + private _currentSketch: CurrentSketch | undefined; + private currentSketchLoaded = new Deferred(); onStart(): void { this.configService.getConfiguration().then(({ sketchDirUri }) => { @@ -110,9 +117,12 @@ export class SketchesServiceClientImpl ); }); }); - this.loadCurrentSketch().then((currentSketch) => - this._currentSketch.resolve(currentSketch) - ); + setTimeout(async () => { + const currentSketch = await this.loadCurrentSketch(); + this._currentSketch = currentSketch; + this.currentSketchDidChangeEmitter.fire(this._currentSketch); + this.currentSketchLoaded.resolve(this._currentSketch); + }, 1_000); } onStop(): void { @@ -143,7 +153,11 @@ export class SketchesServiceClientImpl } async currentSketch(): Promise { - return this._currentSketch.promise; + return this.currentSketchLoaded.promise; + } + + tryGetCurrentSketch(): CurrentSketch | undefined { + return this._currentSketch; } async currentSketchFile(): Promise { diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index eb07572d7..0394e6e94 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -1,5 +1,21 @@ +import { ApplicationError } from '@theia/core/lib/common/application-error'; import URI from '@theia/core/lib/common/uri'; +export namespace SketchesError { + export const Codes = { + NotFound: 5001, + }; + export const NotFound = ApplicationError.declare( + Codes.NotFound, + (message: string, uri: string) => { + return { + message, + data: { uri }, + }; + } + ); +} + export const SketchesServicePath = '/services/sketches-service'; export const SketchesService = Symbol('SketchesService'); export interface SketchesService { diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index c7c74724b..23b6e0f23 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -16,6 +16,7 @@ import { Sketch, SketchRef, SketchContainer, + SketchesError, } from '../common/protocol/sketches-service'; import { firstToLowerCase } from '../common/utils'; import { NotificationServiceServerImpl } from './notification-service-server'; @@ -28,6 +29,7 @@ import { import { duration } from '../common/decorators'; import * as glob from 'glob'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { ServiceError } from './service-error'; const WIN32_DRIVE_REGEXP = /^[a-zA-Z]:\\/; @@ -201,7 +203,11 @@ export class SketchesServiceImpl const sketch = await new Promise((resolve, reject) => { client.loadSketch(req, async (err, resp) => { if (err) { - reject(err); + reject( + isNotFoundError(err) + ? SketchesError.NotFound(err.details, uri) + : err + ); return; } const responseSketchPath = maybeNormalizeDrive(resp.getLocationPath()); @@ -448,26 +454,15 @@ void loop() { private async _isSketchFolder( uri: string ): Promise { - const fsPath = FileUri.fsPath(uri); - let stat: fs.Stats | undefined; try { - stat = await promisify(fs.lstat)(fsPath); - } catch {} - if (stat && stat.isDirectory()) { - const basename = path.basename(fsPath); - const files = await promisify(fs.readdir)(fsPath); - for (let i = 0; i < files.length; i++) { - if (files[i] === basename + '.ino' || files[i] === basename + '.pde') { - try { - const sketch = await this.loadSketch( - FileUri.create(fsPath).toString() - ); - return sketch; - } catch {} - } + const sketch = await this.loadSketch(uri); + return sketch; + } catch (err) { + if (SketchesError.NotFound.is(err)) { + return undefined; } + throw err; } - return undefined; } async isTemp(sketch: SketchRef): Promise { @@ -588,6 +583,14 @@ interface SketchWithDetails extends Sketch { readonly mtimeMs: number; } +function isNotFoundError(err: unknown): err is ServiceError { + return ( + ServiceError.is(err) && + err.code === 5 && + err.message.toLocaleLowerCase('en-US').includes('not_found') + ); +} + /** * If on Windows, will change the input `C:\\path\\to\\somewhere` to `c:\\path\\to\\somewhere`. */