diff --git a/extensions/vscode/webviews/homeView/package-lock.json b/extensions/vscode/webviews/homeView/package-lock.json index 1587d30cd..f4da09d3b 100644 --- a/extensions/vscode/webviews/homeView/package-lock.json +++ b/extensions/vscode/webviews/homeView/package-lock.json @@ -11,7 +11,8 @@ "axios": "1.7.4", "eventsource": "^2.0.2", "pinia": "^2.1.7", - "vue": "^3.4.21" + "vue": "^3.4.21", + "vue-virtual-scroller": "^2.0.0-beta.8" }, "devDependencies": { "@tsconfig/node20": "^20.1.4", @@ -1585,6 +1586,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mitt": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz", + "integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2566,6 +2572,22 @@ } } }, + "node_modules/vue-observe-visibility": { + "version": "2.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz", + "integrity": "sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==", + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-resize": { + "version": "2.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz", + "integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-tsc": { "version": "2.1.10", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.10.tgz", @@ -2583,6 +2605,19 @@ "typescript": ">=5.0.0" } }, + "node_modules/vue-virtual-scroller": { + "version": "2.0.0-beta.8", + "resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-beta.8.tgz", + "integrity": "sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==", + "dependencies": { + "mitt": "^2.1.0", + "vue-observe-visibility": "^2.0.0-alpha.1", + "vue-resize": "^2.0.0-alpha.1" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/extensions/vscode/webviews/homeView/package.json b/extensions/vscode/webviews/homeView/package.json index 3b9269153..ab04b781e 100644 --- a/extensions/vscode/webviews/homeView/package.json +++ b/extensions/vscode/webviews/homeView/package.json @@ -11,7 +11,8 @@ "axios": "1.7.4", "eventsource": "^2.0.2", "pinia": "^2.1.7", - "vue": "^3.4.21" + "vue": "^3.4.21", + "vue-virtual-scroller": "^2.0.0-beta.8" }, "devDependencies": { "@tsconfig/node20": "^20.1.4", diff --git a/extensions/vscode/webviews/homeView/src/HostConduitService.ts b/extensions/vscode/webviews/homeView/src/HostConduitService.ts index 48136d423..e45a90ae4 100644 --- a/extensions/vscode/webviews/homeView/src/HostConduitService.ts +++ b/extensions/vscode/webviews/homeView/src/HostConduitService.ts @@ -20,7 +20,8 @@ import { WebviewToHostMessage, WebviewToHostMessageType, } from "../../../src/types/messages/webviewToHostMessages"; -import { useHomeStore } from "./stores/home"; +import { useFileStore } from "src/stores/file"; +import { useHomeStore } from "src/stores/home"; import { vscodeAPI } from "src/vscode"; let hostConduit: HostConduit | undefined = undefined; @@ -196,8 +197,14 @@ const onSaveSelectionMsg = () => { }; const onRefreshFilesMsg = (msg: RefreshFilesMsg) => { - const home = useHomeStore(); - home.files = msg.content.files; + const fileStore = useFileStore(); + + // If the root file has changed, reset the expanded directories + if (msg.content.files.abs !== fileStore.files?.abs) { + fileStore.expandedDirs = new Set(); + } + + fileStore.files = msg.content.files; }; const onUpdatePythonPackages = (msg: UpdatePythonPackages) => { diff --git a/extensions/vscode/webviews/homeView/src/components/tree/TreeItem.vue b/extensions/vscode/webviews/homeView/src/components/tree/TreeItem.vue index 5a5f6bef5..f23eb5c16 100644 --- a/extensions/vscode/webviews/homeView/src/components/tree/TreeItem.vue +++ b/extensions/vscode/webviews/homeView/src/components/tree/TreeItem.vue @@ -3,7 +3,7 @@ class="tree-item" :class="{ 'align-icon-with-twisty': alignIconWithTwisty, - collapsible: $slots.default, + collapsible: isExpandable, 'text-list-emphasized': listStyle === 'emphasized', 'text-foreground': listStyle === 'default', 'text-list-deemphasized': listStyle === 'deemphasized', @@ -11,9 +11,10 @@ >
@@ -22,8 +23,8 @@
+
diff --git a/extensions/vscode/webviews/homeView/src/components/views/projectFiles/TreeProjectFiles.vue b/extensions/vscode/webviews/homeView/src/components/views/projectFiles/TreeProjectFiles.vue deleted file mode 100644 index ba58a6230..000000000 --- a/extensions/vscode/webviews/homeView/src/components/views/projectFiles/TreeProjectFiles.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - diff --git a/extensions/vscode/webviews/homeView/src/components/views/projectFiles/tooltips.ts b/extensions/vscode/webviews/homeView/src/components/views/projectFiles/tooltips.ts index 1b63c7c88..5f11d8050 100644 --- a/extensions/vscode/webviews/homeView/src/components/views/projectFiles/tooltips.ts +++ b/extensions/vscode/webviews/homeView/src/components/views/projectFiles/tooltips.ts @@ -1,6 +1,8 @@ import { ContentRecordFile, FileMatchSource } from "../../../../../../src/api"; -export function includedFileTooltip(file: ContentRecordFile) { +export function includedFileTooltip( + file: Pick, +) { let tooltip = `${file.rel} will be included in the next deployment.`; if (file.reason) { tooltip += `\nThe configuration file ${file.reason?.fileName} is including it with the pattern '${file.reason?.pattern}'`; @@ -8,7 +10,9 @@ export function includedFileTooltip(file: ContentRecordFile) { return tooltip; } -export function excludedFileTooltip(file: ContentRecordFile) { +export function excludedFileTooltip( + file: Pick, +) { let tooltip = `${file.rel} will be excluded in the next deployment.`; if (file.reason) { if (file.reason.source === FileMatchSource.BUILT_IN) { diff --git a/extensions/vscode/webviews/homeView/src/main.ts b/extensions/vscode/webviews/homeView/src/main.ts index c6ac0f5fd..01acd403e 100644 --- a/extensions/vscode/webviews/homeView/src/main.ts +++ b/extensions/vscode/webviews/homeView/src/main.ts @@ -1,6 +1,7 @@ // Copyright (C) 2024 by Posit Software, PBC. import { createApp } from "vue"; +import { RecycleScroller } from "vue-virtual-scroller"; import { createPinia } from "pinia"; import { provideVSCodeDesignSystem, @@ -14,6 +15,8 @@ import { import App from "src/App.vue"; +import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; + import "src/style.css"; // In order to use the Webview UI Toolkit web components they @@ -30,4 +33,8 @@ provideVSCodeDesignSystem().register( const pinia = createPinia(); -createApp(App).use(pinia).mount("#app"); +const app = createApp(App); +app.use(pinia); +app.component("RecycleScroller", RecycleScroller); + +app.mount("#app"); diff --git a/extensions/vscode/webviews/homeView/src/stores/file.test.ts b/extensions/vscode/webviews/homeView/src/stores/file.test.ts new file mode 100644 index 000000000..66d1c8f38 --- /dev/null +++ b/extensions/vscode/webviews/homeView/src/stores/file.test.ts @@ -0,0 +1,90 @@ +import { setActivePinia, createPinia } from "pinia"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { WebviewApi } from "vscode-webview"; + +import { vscodeAPI } from "src/vscode"; +import { useFileStore } from "src/stores/file"; + +vi.mock(import("src/vscode")); + +vi.mock("src/vscode", () => { + const postMessage = vi.fn(); + + const vscodeAPI = vi.fn(() => ({ + postMessage: postMessage, + })); + + return { vscodeAPI }; +}); + +describe("File Store", () => { + let vscodeApi: WebviewApi; + + beforeEach(() => { + vscodeApi = vscodeAPI(); + + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + test("initializes with no expanded directories", () => { + const file = useFileStore(); + expect(file.expandedDirs.size).toBe(0); + }); + + test("refreshing files sends a message to vscode", () => { + const file = useFileStore(); + file.refreshFiles(); + expect(vscodeApi.postMessage).toHaveBeenCalledWith( + JSON.stringify({ + kind: "requestFilesLists", + }), + ); + }); + + test("including a file sends a message to vscode", () => { + const file = useFileStore(); + file.includeFile({ id: "file" }); + expect(vscodeApi.postMessage).toHaveBeenCalledWith( + JSON.stringify({ + kind: "includeFile", + content: { path: "file" }, + }), + ); + }); + + test("excluding a file sends a message to vscode", () => { + const file = useFileStore(); + file.excludeFile({ id: "file" }); + expect(vscodeApi.postMessage).toHaveBeenCalledWith( + JSON.stringify({ + kind: "excludeFile", + content: { path: "file" }, + }), + ); + }); + + test("opening a file sends a message to vscode", () => { + const file = useFileStore(); + file.openFile({ id: "file" }); + expect(vscodeApi.postMessage).toHaveBeenCalledWith( + JSON.stringify({ + kind: "VSCodeOpenRelativeMsg", + content: { relativePath: "file" }, + }), + ); + }); + + test("expanding a directory adds it to the expanded directories", () => { + const file = useFileStore(); + file.expandDir({ id: "dir" }); + expect(file.expandedDirs.has("dir")).toBe(true); + }); + + test("collapsing a directory removes it from the expanded directories", () => { + const file = useFileStore(); + file.expandDir({ id: "dir" }); + file.collapseDir({ id: "dir" }); + expect(file.expandedDirs.has("dir")).toBe(false); + }); +}); diff --git a/extensions/vscode/webviews/homeView/src/stores/file.ts b/extensions/vscode/webviews/homeView/src/stores/file.ts new file mode 100644 index 000000000..f87c627f1 --- /dev/null +++ b/extensions/vscode/webviews/homeView/src/stores/file.ts @@ -0,0 +1,75 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +import { useHomeStore } from "src/stores/home"; +import { useHostConduitService } from "src/HostConduitService"; +import { flattenFiles } from "src/utils/files"; + +import { ContentRecordFile } from "../../../../src/api/types/files"; +import { WebviewToHostMessageType } from "../../../../src/types/messages/webviewToHostMessages"; + +export const useFileStore = defineStore("file", () => { + const home = useHomeStore(); + const { sendMsg } = useHostConduitService(); + + const files = ref(); + + const expandedDirs = ref>(new Set()); + + const flatFiles = computed(() => + flattenFiles(files.value?.files || [], expandedDirs.value), + ); + + const lastDeployedFiles = computed((): Set => { + if (home.selectedContentRecord?.state !== "new") { + return new Set(home.selectedContentRecord?.files); + } + return new Set(); + }); + + function refreshFiles() { + sendMsg({ kind: WebviewToHostMessageType.REQUEST_FILES_LISTS }); + } + + function includeFile({ id }: Pick) { + sendMsg({ + kind: WebviewToHostMessageType.INCLUDE_FILE, + content: { path: id }, + }); + } + + function excludeFile({ id }: Pick) { + sendMsg({ + kind: WebviewToHostMessageType.EXCLUDE_FILE, + content: { path: id }, + }); + } + + function openFile({ id }: Pick) { + sendMsg({ + kind: WebviewToHostMessageType.VSCODE_OPEN_RELATIVE, + content: { relativePath: id }, + }); + } + + function expandDir({ id }: Pick) { + expandedDirs.value.add(id); + } + + function collapseDir({ id }: Pick) { + expandedDirs.value.delete(id); + } + + return { + files, + expandedDirs, + lastDeployedFiles, + flatFiles, + refreshFiles, + includeFile, + excludeFile, + openFile, + expandDir, + collapseDir, + }; +}); diff --git a/extensions/vscode/webviews/homeView/src/stores/home.ts b/extensions/vscode/webviews/homeView/src/stores/home.ts index 2763ee03c..6c71de4cf 100644 --- a/extensions/vscode/webviews/homeView/src/stores/home.ts +++ b/extensions/vscode/webviews/homeView/src/stores/home.ts @@ -8,14 +8,12 @@ import { ContentRecord, PreContentRecord, Configuration, - ContentRecordFile, ConfigurationError, } from "../../../../src/api"; import { isConfigurationError } from "../../../../src/api/types/configurations"; import { WebviewToHostMessageType } from "../../../../src/types/messages/webviewToHostMessages"; import { RPackage } from "../../../../src/api/types/packages"; import { DeploymentSelector } from "../../../../src/types/shared"; -import { splitFilesOnInclusion } from "src/utils/files"; import { isAgentErrorInvalidTOML, isAgentErrorTypeUnknown, @@ -136,25 +134,6 @@ export const useHomeStore = defineStore("home", () => { const lastContentRecordResult = ref(); const lastContentRecordMsg = ref(); - const files = ref(); - - const flatFiles = computed(() => { - const response: { - includedFiles: ContentRecordFile[]; - excludedFiles: ContentRecordFile[]; - lastDeployedFiles: Set; - } = { includedFiles: [], excludedFiles: [], lastDeployedFiles: new Set() }; - if (files.value) { - splitFilesOnInclusion(files.value, response); - } - - if (selectedContentRecord.value?.state !== "new") { - response.lastDeployedFiles = new Set(selectedContentRecord.value?.files); - } - - return response; - }); - const pythonProject = ref(false); const pythonPackages = ref(); const pythonPackageFile = ref(); @@ -423,8 +402,6 @@ export const useHomeStore = defineStore("home", () => { selectedContentRecord, selectedConfiguration, serverCredential, - files, - flatFiles, initializingRequestComplete, lastContentRecordResult, lastContentRecordMsg, diff --git a/extensions/vscode/webviews/homeView/src/utils/files.ts b/extensions/vscode/webviews/homeView/src/utils/files.ts index 6e215bc04..a4429867a 100644 --- a/extensions/vscode/webviews/homeView/src/utils/files.ts +++ b/extensions/vscode/webviews/homeView/src/utils/files.ts @@ -1,33 +1,5 @@ import { ContentRecordFile } from "../../../../src/api"; -export function splitFilesOnInclusion( - file: ContentRecordFile, - response: { - includedFiles: ContentRecordFile[]; - excludedFiles: ContentRecordFile[]; - } = { includedFiles: [], excludedFiles: [] }, -): { - includedFiles: ContentRecordFile[]; - excludedFiles: ContentRecordFile[]; -} { - if (file.isFile) { - if (file.reason?.exclude === false) { - response.includedFiles.push(file); - } else { - response.excludedFiles.push(file); - } - } else { - // Don't include .posit files in the response - if (file.id === ".posit") { - return response; - } - } - - file.files.forEach((f) => splitFilesOnInclusion(f, response)); - - return response; -} - export function canFileBeIncluded(file: ContentRecordFile): boolean { return Boolean(file.reason?.exclude && file.reason?.source !== "built-in"); } @@ -35,3 +7,41 @@ export function canFileBeIncluded(file: ContentRecordFile): boolean { export function canFileBeExcluded(file: ContentRecordFile): boolean { return Boolean(!file.reason?.exclude); } + +export type FlatFile = Omit & { + indent: number; + parent?: string; +}; + +/** + * Flattens a ContentRecordFile[] into a FlatFile[] array + * + * @param files The ContentRecordFile[] to flatten + * @param expandedDirs The Set of expanded directories + * @param arr The array to push the flattened files to + * @param indent The current indent level + * @param parentFile The parent file of the directory being flattened + * @returns + */ +export function flattenFiles( + files: ContentRecordFile[], + expandedDirs: Set, + arr = new Array(), + indent = 0, + parentFile?: string, +): FlatFile[] { + files.forEach((file) => { + const { files, ...rest } = file; + const flatFile = { + ...rest, + indent: indent, + parent: parentFile, + }; + arr.push(flatFile); + if (file.files.length > 0 && expandedDirs.has(file.id)) { + flattenFiles(file.files, expandedDirs, arr, indent + 1, file.id); + } + }); + + return arr; +} diff --git a/extensions/vscode/webviews/homeView/src/vue-virtual-scroller.d.ts b/extensions/vscode/webviews/homeView/src/vue-virtual-scroller.d.ts new file mode 100644 index 000000000..d12239901 --- /dev/null +++ b/extensions/vscode/webviews/homeView/src/vue-virtual-scroller.d.ts @@ -0,0 +1,5 @@ +declare module "vue-virtual-scroller" { + import { Component } from "vue"; + + export const RecycleScroller: Component; +}