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 @@
-
-
-
-
-
-
-
-
- A
-
-
- R
-
-
-
-
-
-
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;
+}