Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DEMO: implement Projects view and basic project tracking functionality #513

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"templates"
],
"activationEvents": [
"workspaceContains:**/*.confluent.*"
"workspaceContains:**/*.confluent.*",
"workspaceContains:*.confluent.*"
],
"main": "./out/extension.js",
"contributes": {
Expand Down Expand Up @@ -128,6 +129,12 @@
"title": "Copy Organization ID",
"category": "Confluent: Resources"
},
{
"command": "confluent.projects.refresh",
"icon": "$(sync)",
"title": "Refresh",
"category": "Confluent: Projects"
},
{
"command": "confluent.resources.refresh",
"icon": "$(sync)",
Expand Down Expand Up @@ -165,6 +172,7 @@
},
{
"command": "confluent.scaffold",
"icon": "$(file-code)",
"title": "Generate New Project",
"category": "Confluent: Project"
},
Expand Down Expand Up @@ -336,6 +344,11 @@
},
"views": {
"confluent": [
{
"id": "confluent-projects",
"name": "Projects",
"visibility": "collapsed"
},
{
"id": "confluent-resources",
"name": "Resources"
Expand All @@ -357,6 +370,10 @@
]
},
"viewsWelcome": [
{
"view": "confluent-projects",
"contents": "No projects found.\n[Generate New Project](command:confluent.scaffold)"
},
{
"view": "confluent-resources",
"contents": "No resources found."
Expand Down Expand Up @@ -521,6 +538,16 @@
}
],
"view/title": [
{
"command": "confluent.scaffold",
"when": "view == confluent-projects",
"group": "navigation@1"
},
{
"command": "confluent.projects.refresh",
"when": "view == confluent-projects",
"group": "navigation@2"
},
{
"command": "confluent.resources.refresh",
"when": "view == confluent-resources",
Expand Down
11 changes: 11 additions & 0 deletions src/commands/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { commands, Disposable, Uri } from "vscode";
import { registerCommandWithLogging } from ".";
import { Project } from "../models/project";

async function openProject(project: Project): Promise<void> {
commands.executeCommand("vscode.openFolder", Uri.file(project.fsPath), true);
}

export function registerProjectCommands(): Disposable[] {
return [registerCommandWithLogging("confluent.projects.open", openProject)];
}
15 changes: 15 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { registerEnvironmentCommands } from "./commands/environments";
import { registerExtraCommands } from "./commands/extra";
import { registerKafkaClusterCommands } from "./commands/kafkaClusters";
import { registerOrganizationCommands } from "./commands/organizations";
import { registerProjectCommands } from "./commands/projects";
import { registerSchemaRegistryCommands } from "./commands/schemaRegistry";
import { registerSchemaCommands } from "./commands/schemas";
import { registerSupportCommands } from "./commands/support";
Expand All @@ -66,6 +67,7 @@ import { migrateStorageIfNeeded } from "./storage/migrationManager";
import { sendTelemetryIdentifyEvent } from "./telemetry/telemetry";
import { getTelemetryLogger } from "./telemetry/telemetryLogger";
import { getUriHandler } from "./uriHandler";
import { ProjectsViewProvider } from "./viewProviders/projects";
import { ResourceViewProvider } from "./viewProviders/resources";
import { SchemasViewProvider } from "./viewProviders/schemas";
import { SupportViewProvider } from "./viewProviders/support";
Expand Down Expand Up @@ -322,6 +324,18 @@ function setupViewProviders(context: vscode.ExtensionContext): vscode.ExtensionC
logger.error("Error creating Support view provider", e);
}

try {
const projectsViewProvider = ProjectsViewProvider.getInstance();
context.subscriptions.push(
registerCommandWithLogging("confluent.projects.refresh", () => {
projectsViewProvider.refresh();
}),
);
logger.info("Projects view provider created");
} catch (e) {
logger.error("Error creating Projects view provider:", e);
}

return context;
}

Expand All @@ -342,6 +356,7 @@ function setupCommands(context: vscode.ExtensionContext): vscode.ExtensionContex
...registerTopicCommands(),
...registerDiffCommands(),
...registerExtraCommands(),
...registerProjectCommands(),
);
logger.info("Main command disposables stored");
return context;
Expand Down
26 changes: 26 additions & 0 deletions src/models/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Data, type Require as Enforced } from "dataclass";
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode";

export class Project extends Data {
name!: Enforced<string>;
templateId!: Enforced<string>;
createdAt!: Enforced<string>;
fsPath!: Enforced<string>;
}

export class ProjectTreeItem extends TreeItem {
resource: Project;

constructor(resource: Project) {
super(resource.name, TreeItemCollapsibleState.None);
this.resource = resource;
this.description = resource.createdAt;
this.iconPath = new ThemeIcon("project");
this.tooltip = JSON.stringify(resource, null, 2);
this.command = {
command: "confluent.projects.open",
title: "Open Project",
arguments: [resource],
};
}
}
30 changes: 26 additions & 4 deletions src/scaffold.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import * as vscode from "vscode";
import * as Sentry from "@sentry/node";
import * as vscode from "vscode";

import { posix } from "path";
import { unzip } from "unzipit";
import { Template, TemplateList, TemplateManifest, TemplatesApi } from "./clients/sidecar";
import { Logger } from "./logging";
import { getSidecar } from "./sidecar";

import { ExtensionContext, Uri, ViewColumn } from "vscode";
import { ExtensionContext, ViewColumn } from "vscode";
import { registerCommandWithLogging } from "./commands";
import { Project } from "./models/project";
import { getResourceManager } from "./storage/resourceManager";
import { getTelemetryLogger } from "./telemetry/telemetryLogger";
import { ProjectsViewProvider } from "./viewProviders/projects";
import { WebviewPanelCache } from "./webview-cache";
import { handleWebviewMessage } from "./webview/comms/comms";
import { type post } from "./webview/scaffold-form";
Expand Down Expand Up @@ -140,6 +143,17 @@ async function applyTemplate(
const destination = await getNonConflictingDirPath(fileUris[0], pickedTemplate);

await extractZipContents(arrayBuffer, destination);

// store the project in the global state to populate the Projects view
const project = Project.create({
name: pickedTemplate.spec.display_name,
templateId: pickedTemplate.id,
createdAt: new Date().toISOString(),
fsPath: destination.path,
});
await getResourceManager().addProject(project);
ProjectsViewProvider.getInstance().refresh();

getTelemetryLogger().logUsage("Scaffold Completed", {
templateName: pickedTemplate.spec.display_name,
});
Expand All @@ -151,10 +165,11 @@ async function applyTemplate(
{ title: "Open in Current Window" },
{ title: "Dismiss", isCloseAffordance: true },
);
if (selection !== undefined) {
logger.debug(`Project generated at ${destination.path}`, { selection });
if (selection?.title !== "Dismiss") {
// if "true" is set in the `vscode.openFolder` command, it will open a new window instead of
// reusing the current one
const keepsExistingWindow = selection.title === "Open in New Window";
const keepsExistingWindow = selection?.title === "Open in New Window";
getTelemetryLogger().logUsage("Scaffold Folder Opened", {
templateName: pickedTemplate.spec.display_name,
keepsExistingWindow,
Expand Down Expand Up @@ -192,6 +207,13 @@ async function extractZipContents(buffer: ArrayBuffer, destination: vscode.Uri)
new Uint8Array(entryBuffer),
);
}
// also write a `.confluent.version` file to the root of the project indicating the extension version
await vscode.workspace.fs.writeFile(
vscode.Uri.file(posix.join(destination.path, ".confluent.vscode-confluent.version")),
new TextEncoder().encode(
vscode.extensions.getExtension("confluentinc.vscode-confluent")?.packageJSON.version,
),
);
} catch (err) {
throw new Error(`Failed to extract zip contents: ${err}`);
}
Expand Down
2 changes: 1 addition & 1 deletion src/storage/migrationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function migrateStorageIfNeeded(manager: StorageManager): Promise<v
// While in EA, just blow away the storage every time. We don't want to deal with migrations
// yet, and there's nothing critical in the extension right now that depends on persisted state
// across extension reloads.
await manager.clearGlobalState();
// await manager.clearGlobalState();
await manager.clearWorkspaceState();

// But when we do want to start migrating storage, we can use the following logic to check the
Expand Down
12 changes: 12 additions & 0 deletions src/storage/resourceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Status } from "../clients/sidecar";
import { Logger } from "../logging";
import { CCloudEnvironment } from "../models/environment";
import { CCloudKafkaCluster, KafkaCluster, LocalKafkaCluster } from "../models/kafkaCluster";
import { Project } from "../models/project";
import { Schema } from "../models/schema";
import { CCloudSchemaRegistry } from "../models/schemaRegistry";
import { KafkaTopic } from "../models/topic";
Expand Down Expand Up @@ -479,6 +480,17 @@ export class ResourceManager {
async getCCloudAuthStatus(): Promise<string | undefined> {
return await this.storage.getSecret(CCLOUD_AUTH_STATUS_KEY);
}

async getProjects(): Promise<Project[]> {
const projectObjs: Project[] = (await this.storage.getGlobalState("projects")) ?? [];
return projectObjs.map((project) => Project.create(project));
}

async addProject(project: Project) {
const projects = await this.getProjects();
projects.push(project);
await this.storage.setGlobalState("projects", projects);
}
}

/**
Expand Down
62 changes: 62 additions & 0 deletions src/viewProviders/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as vscode from "vscode";
import { getExtensionContext } from "../context";
import { ExtensionContextNotSetError } from "../errors";
import { Logger } from "../logging";
import { Project, ProjectTreeItem } from "../models/project";
import { getResourceManager } from "../storage/resourceManager";

const logger = new Logger("viewProviders.projects");

/**
* The types managed by the {@link ProjectsViewProvider}, which are converted to their appropriate tree item
* type via the `getTreeItem()` method.
*/
type ProjectsViewProviderData = Project;

export class ProjectsViewProvider implements vscode.TreeDataProvider<ProjectsViewProviderData> {
private _onDidChangeTreeData: vscode.EventEmitter<ProjectsViewProviderData | undefined | void> =
new vscode.EventEmitter<ProjectsViewProviderData | undefined | void>();
readonly onDidChangeTreeData: vscode.Event<ProjectsViewProviderData | undefined | void> =
this._onDidChangeTreeData.event;
refresh(): void {
this._onDidChangeTreeData.fire();
}

private treeView: vscode.TreeView<ProjectsViewProviderData>;

private static instance: ProjectsViewProvider | null = null;
private constructor() {
if (!getExtensionContext()) {
// getChildren() will fail without the extension context
throw new ExtensionContextNotSetError("ProjectsViewProvider");
}

this.treeView = vscode.window.createTreeView("confluent-projects", { treeDataProvider: this });
}

static getInstance(): ProjectsViewProvider {
if (!ProjectsViewProvider.instance) {
ProjectsViewProvider.instance = new ProjectsViewProvider();
}
return ProjectsViewProvider.instance;
}

getTreeItem(element: ProjectsViewProviderData): vscode.TreeItem | ProjectTreeItem {
if (element instanceof Project) {
return new ProjectTreeItem(element);
}
return element;
}

async getChildren(element?: ProjectsViewProviderData): Promise<ProjectsViewProviderData[]> {
if (!element) {
const projects: Project[] = await getResourceManager().getProjects();
logger.debug("loaded projects from global state:", projects);
return projects.map((project) => {
return Project.create(project);
});
}

return [];
}
}