diff --git a/packages/backend/src/managers/modelsManager.spec.ts b/packages/backend/src/managers/modelsManager.spec.ts index 47f1faf55..83f301309 100644 --- a/packages/backend/src/managers/modelsManager.spec.ts +++ b/packages/backend/src/managers/modelsManager.spec.ts @@ -7,6 +7,27 @@ import type { Webview } from '@podman-desktop/api'; import type { CatalogManager } from './catalogManager'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; +const mocks = vi.hoisted(() => { + return { + showErrorMessageMock: vi.fn(), + }; +}); + +vi.mock('@podman-desktop/api', () => { + return { + fs: { + createFileSystemWatcher: () => ({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + onDidChange: vi.fn(), + }), + }, + window: { + showErrorMessage: mocks.showErrorMessageMock, + }, + }; +}); + beforeEach(() => { vi.resetAllMocks(); }); @@ -34,7 +55,7 @@ function mockFiles(now: Date) { const existsSyncSpy = vi.spyOn(fs, 'existsSync'); existsSyncSpy.mockImplementation((path: string) => { if (process.platform === 'win32') { - expect(path).toBe('\\home\\user\\aistudio\\models'); + expect(path).toBe('C:\\home\\user\\aistudio\\models'); } else { expect(path).toBe('/home/user/aistudio/models'); } @@ -62,7 +83,13 @@ function mockFiles(now: Date) { test('getLocalModelsFromDisk should get models in local directory', () => { const now = new Date(); mockFiles(now); - const manager = new ModelsManager('/home/user/aistudio', {} as Webview, {} as CatalogManager); + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const manager = new ModelsManager(appdir, {} as Webview, {} as CatalogManager); manager.getLocalModelsFromDisk(); expect(manager.getLocalModels()).toEqual([ { @@ -86,11 +113,17 @@ test('getLocalModelsFromDisk should return an empty array if the models folder d vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); const existsSyncSpy = vi.spyOn(fs, 'existsSync'); existsSyncSpy.mockReturnValue(false); - const manager = new ModelsManager('/home/user/aistudio', {} as Webview, {} as CatalogManager); + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const manager = new ModelsManager(appdir, {} as Webview, {} as CatalogManager); manager.getLocalModelsFromDisk(); expect(manager.getLocalModels()).toEqual([]); if (process.platform === 'win32') { - expect(existsSyncSpy).toHaveBeenCalledWith('\\home\\user\\aistudio\\models'); + expect(existsSyncSpy).toHaveBeenCalledWith('C:\\home\\user\\aistudio\\models'); } else { expect(existsSyncSpy).toHaveBeenCalledWith('/home/user/aistudio/models'); } @@ -100,20 +133,15 @@ test('loadLocalModels should post a message with the message on disk and on cata const now = new Date(); mockFiles(now); - vi.mock('@podman-desktop/api', () => { - return { - fs: { - createFileSystemWatcher: () => ({ - onDidCreate: vi.fn(), - onDidDelete: vi.fn(), - onDidChange: vi.fn(), - }), - }, - }; - }); const postMessageMock = vi.fn(); + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } const manager = new ModelsManager( - '/home/user/aistudio', + appdir, { postMessage: postMessageMock, } as unknown as Webview, @@ -144,3 +172,135 @@ test('loadLocalModels should post a message with the message on disk and on cata ], }); }); + +test('deleteLocalModel deletes the model folder', async () => { + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const now = new Date(); + mockFiles(now); + const rmSpy = vi.spyOn(fs.promises, 'rm'); + rmSpy.mockResolvedValue(); + const postMessageMock = vi.fn(); + const manager = new ModelsManager( + appdir, + { + postMessage: postMessageMock, + } as unknown as Webview, + { + getModels: () => { + return [ + { + id: 'model-id-1', + }, + ] as ModelInfo[]; + }, + } as CatalogManager, + ); + manager.getLocalModelsFromDisk(); + await manager.deleteLocalModel('model-id-1'); + // check that the model's folder is removed from disk + if (process.platform === 'win32') { + expect(rmSpy).toBeCalledWith('C:\\home\\user\\aistudio\\models\\model-id-1', { recursive: true }); + } else { + expect(rmSpy).toBeCalledWith('/home/user/aistudio/models/model-id-1', { recursive: true }); + } + expect(postMessageMock).toHaveBeenCalledTimes(2); + // check that a state is sent with the model being deleted + expect(postMessageMock).toHaveBeenCalledWith({ + id: 'new-local-models-state', + body: [ + { + file: { + creation: now, + file: 'model-id-1-model', + id: 'model-id-1', + size: 32000, + path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'), + }, + id: 'model-id-1', + state: 'deleting', + }, + ], + }); + // check that a new state is sent with the model removed + expect(postMessageMock).toHaveBeenCalledWith({ + id: 'new-local-models-state', + body: [], + }); +}); + +test('deleteLocalModel fails to delete the model folder', async () => { + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const now = new Date(); + mockFiles(now); + const rmSpy = vi.spyOn(fs.promises, 'rm'); + rmSpy.mockRejectedValue(new Error('failed')); + const postMessageMock = vi.fn(); + const manager = new ModelsManager( + appdir, + { + postMessage: postMessageMock, + } as unknown as Webview, + { + getModels: () => { + return [ + { + id: 'model-id-1', + }, + ] as ModelInfo[]; + }, + } as CatalogManager, + ); + manager.getLocalModelsFromDisk(); + await manager.deleteLocalModel('model-id-1'); + // check that the model's folder is removed from disk + if (process.platform === 'win32') { + expect(rmSpy).toBeCalledWith('C:\\home\\user\\aistudio\\models\\model-id-1', { recursive: true }); + } else { + expect(rmSpy).toBeCalledWith('/home/user/aistudio/models/model-id-1', { recursive: true }); + } + expect(postMessageMock).toHaveBeenCalledTimes(2); + // check that a state is sent with the model being deleted + expect(postMessageMock).toHaveBeenCalledWith({ + id: 'new-local-models-state', + body: [ + { + file: { + creation: now, + file: 'model-id-1-model', + id: 'model-id-1', + size: 32000, + path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'), + }, + id: 'model-id-1', + state: 'deleting', + }, + ], + }); + // check that a new state is sent with the model non removed + expect(postMessageMock).toHaveBeenCalledWith({ + id: 'new-local-models-state', + body: [ + { + file: { + creation: now, + file: 'model-id-1-model', + id: 'model-id-1', + size: 32000, + path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'), + }, + id: 'model-id-1', + }, + ], + }); + expect(mocks.showErrorMessageMock).toHaveBeenCalledOnce(); +}); diff --git a/packages/backend/src/managers/modelsManager.ts b/packages/backend/src/managers/modelsManager.ts index b2a266222..0c6e6224e 100644 --- a/packages/backend/src/managers/modelsManager.ts +++ b/packages/backend/src/managers/modelsManager.ts @@ -4,10 +4,14 @@ import * as path from 'node:path'; import { type Webview, fs as apiFs } from '@podman-desktop/api'; import { MSG_NEW_LOCAL_MODELS_STATE } from '@shared/Messages'; import type { CatalogManager } from './catalogManager'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import * as podmanDesktopApi from '@podman-desktop/api'; export class ModelsManager { #modelsDir: string; #localModels: Map; + // models being deleted + #deleted: Set; constructor( private appUserDirectory: string, @@ -16,16 +20,13 @@ export class ModelsManager { ) { this.#modelsDir = path.join(this.appUserDirectory, 'models'); this.#localModels = new Map(); + this.#deleted = new Set(); } async loadLocalModels() { const reloadLocalModels = async () => { this.getLocalModelsFromDisk(); - const models = this.getModelsInfo(); - await this.webview.postMessage({ - id: MSG_NEW_LOCAL_MODELS_STATE, - body: models, - }); + await this.sendModelsInfo(); }; const watcher = apiFs.createFileSystemWatcher(this.#modelsDir); watcher.onDidCreate(reloadLocalModels); @@ -39,7 +40,22 @@ export class ModelsManager { return this.catalogManager .getModels() .filter(m => this.#localModels.has(m.id)) - .map(m => ({ ...m, file: this.#localModels.get(m.id) })); + .map( + m => + ({ + ...m, + file: this.#localModels.get(m.id), + state: this.#deleted.has(m.id) ? 'deleting' : undefined, + }) as ModelInfo, + ); + } + + async sendModelsInfo() { + const models = this.getModelsInfo(); + await this.webview.postMessage({ + id: MSG_NEW_LOCAL_MODELS_STATE, + body: models, + }); } getLocalModelsFromDisk(): void { @@ -85,7 +101,26 @@ export class ModelsManager { return path.resolve(this.#modelsDir, modelId, info.file); } + getLocalModelFolder(modelId: string): string { + return path.resolve(this.#modelsDir, modelId); + } + getLocalModels(): LocalModelInfo[] { return Array.from(this.#localModels.values()); } + + async deleteLocalModel(modelId: string): Promise { + const modelDir = this.getLocalModelFolder(modelId); + this.#deleted.add(modelId); + await this.sendModelsInfo(); + try { + await fs.promises.rm(modelDir, { recursive: true }); + this.#localModels.delete(modelId); + } catch (err: unknown) { + await podmanDesktopApi.window.showErrorMessage(`Error deleting model ${modelId}. ${String(err)}`); + } finally { + this.#deleted.delete(modelId); + await this.sendModelsInfo(); + } + } } diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index cc340f9f4..e166faa69 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -107,4 +107,8 @@ export class StudioApiImpl implements StudioAPI { async getCatalog(): Promise { return this.catalogManager.getCatalog(); } + + async deleteLocalModel(modelId: string): Promise { + await this.modelsManager.deleteLocalModel(modelId); + } } diff --git a/packages/frontend/src/lib/Modal.svelte b/packages/frontend/src/lib/Modal.svelte new file mode 100644 index 000000000..16c169244 --- /dev/null +++ b/packages/frontend/src/lib/Modal.svelte @@ -0,0 +1,62 @@ + + + + + + + + + diff --git a/packages/frontend/src/lib/button/ListItemButtonIcon.svelte b/packages/frontend/src/lib/button/ListItemButtonIcon.svelte new file mode 100644 index 000000000..66e083598 --- /dev/null +++ b/packages/frontend/src/lib/button/ListItemButtonIcon.svelte @@ -0,0 +1,51 @@ + + + diff --git a/packages/frontend/src/lib/dialog-utils.ts b/packages/frontend/src/lib/dialog-utils.ts new file mode 100644 index 000000000..cb97d595d --- /dev/null +++ b/packages/frontend/src/lib/dialog-utils.ts @@ -0,0 +1,32 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export function tabWithinParent(e: KeyboardEvent, parent: HTMLDivElement): void { + // trap focus within parent element + const nodes = parent.querySelectorAll('*'); + const tabbable = Array.from(nodes).filter(n => n.tabIndex >= 0); + + let index = tabbable.indexOf(document.activeElement as HTMLElement); + if (index === -1 && e.shiftKey) index = 0; + + index += tabbable.length + (e.shiftKey ? -1 : 1); + index %= tabbable.length; + + tabbable[index].focus(); + e.preventDefault(); +} diff --git a/packages/frontend/src/lib/table/model/ModelColumnActions.svelte b/packages/frontend/src/lib/table/model/ModelColumnActions.svelte new file mode 100644 index 000000000..6c596c721 --- /dev/null +++ b/packages/frontend/src/lib/table/model/ModelColumnActions.svelte @@ -0,0 +1,53 @@ + + + deleteModel()} + title="Delete Model" + enabled={!object.state} +/> + +{#if deleteConfirmVisible} + +
+

Delete a model

+ + +
+
+ The folder on disk containing the model will be deleted, it contains: +
    +
  • {object.file?.file}
  • +
+ +
+ + +
+
+
+{/if} diff --git a/packages/frontend/src/pages/Models.svelte b/packages/frontend/src/pages/Models.svelte index 4420ec43b..964d97476 100644 --- a/packages/frontend/src/pages/Models.svelte +++ b/packages/frontend/src/pages/Models.svelte @@ -15,7 +15,8 @@ import Card from '/@/lib/Card.svelte'; import { modelsPulling } from '../stores/recipe'; import { onMount } from 'svelte'; import ModelColumnSize from '../lib/table/model/ModelColumnSize.svelte'; - import ModelColumnCreation from '../lib/table/model/ModelColumnCreation.svelte'; +import ModelColumnCreation from '../lib/table/model/ModelColumnCreation.svelte'; +import ModelColumnActions from '../lib/table/model/ModelColumnActions.svelte'; const columns: Column[] = [ new Column('Name', { width: '3fr', renderer: ModelColumnName }), @@ -25,6 +26,7 @@ const columns: Column[] = [ new Column('Registry', { width: '2fr', renderer: ModelColumnRegistry }), new Column('Popularity', { width: '1fr', renderer: ModelColumnPopularity }), new Column('License', { width: '2fr', renderer: ModelColumnLicense }), + new Column('Actions', { align: 'right', width: '1fr', renderer: ModelColumnActions }), ]; const row = new Row({}); diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index 5ea1990eb..98928932b 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -12,10 +12,13 @@ export abstract class StudioAPI { abstract pullApplication(recipeId: string): Promise; abstract openURL(url: string): Promise; /** - * Get the information of models saved locally into the extension's storage directory + * Get the information of models saved locally into the user's directory */ abstract getLocalModels(): Promise; - + /** + * Delete the folder containing the model from local storage + */ + abstract deleteLocalModel(modelId: string): Promise; abstract startPlayground(modelId: string): Promise; abstract stopPlayground(modelId: string): Promise; abstract askPlayground(modelId: string, prompt: string): Promise; diff --git a/packages/shared/src/models/IModelInfo.ts b/packages/shared/src/models/IModelInfo.ts index 422078fce..ac5eea068 100644 --- a/packages/shared/src/models/IModelInfo.ts +++ b/packages/shared/src/models/IModelInfo.ts @@ -10,4 +10,5 @@ export interface ModelInfo { license: string; url: string; file?: LocalModelInfo; + state?: 'deleting'; }