diff --git a/src/explorer/kafkaExplorer.ts b/src/explorer/kafkaExplorer.ts index 97b3a2e..60d6358 100644 --- a/src/explorer/kafkaExplorer.ts +++ b/src/explorer/kafkaExplorer.ts @@ -1,57 +1,119 @@ import * as vscode from "vscode"; -import { Cluster, ClientAccessor } from "../client"; +import { ClientAccessor } from "../client"; import { WorkspaceSettings, ClusterSettings } from "../settings"; -import { InformationItem } from "./models/common"; import { NodeBase } from "./models/nodeBase"; +import { TreeView } from "vscode"; +import { KafkaModel } from "./models/kafka"; import { ClusterItem } from "./models/cluster"; +const TREEVIEW_ID = 'kafkaExplorer'; + +/** + * Kafka explorer to show in a tree clusters, topics. + */ export class KafkaExplorer implements vscode.Disposable, vscode.TreeDataProvider { + private onDidChangeTreeDataEvent: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangeTreeData?: vscode.Event | undefined + readonly onDidChangeTreeData?: vscode.Event | undefined = this.onDidChangeTreeDataEvent.event; private readonly clusterSettings: ClusterSettings; private readonly clientAccessor: ClientAccessor; + protected tree: TreeView | undefined; + + private root: KafkaModel | null; + constructor( - settings: WorkspaceSettings, + settings: WorkspaceSettings, clusterSettings: ClusterSettings, clientAccessor: ClientAccessor) { this.clusterSettings = clusterSettings; this.clientAccessor = clientAccessor; + this.root = null; + this.tree = vscode.window.createTreeView(TREEVIEW_ID, { + treeDataProvider: this + }); } public refresh(): void { + // reset the kafka model + this.root = null; + // refresh the treeview this.onDidChangeTreeDataEvent.fire(undefined); + this.show(); + } + + private show(): void { + vscode.commands.executeCommand(`${TREEVIEW_ID}.focus`); } public getTreeItem(element: NodeBase): vscode.TreeItem | Thenable { return element.getTreeItem(); } - public getChildren(element?: NodeBase): vscode.ProviderResult { - const clusters = this.clusterSettings.getAll(); + async getChildren(element?: NodeBase): Promise { + if (!element) { + if (!this.root) { + this.root = new KafkaModel(this.clusterSettings, this.clientAccessor); + } + element = this.root; + } + return element.getChildren(); + } - if (clusters.length === 0) { - return [new InformationItem("No clusters added")]; + public getParent(element: NodeBase): NodeBase | undefined { + if (element instanceof ClusterItem) { + return undefined; } + return element.getParent(); + } - if (!element) { - return this.getGroupChildren(clusters); + public dispose(): void { + if (this.root) { + this.root.dispose(); } + } - return element.getChildren(element); + /** + * Select the given cluster name in the tree. + * + * @param clusterName the cluster name to select. + */ + async selectClusterByName(clusterName: string): Promise { + const clusterItem = await this.root?.findClusterItemByName(clusterName); + if (!clusterItem) { + return; + } + this.selectItem(clusterItem); } - public dispose(): void { - // noop + /** + * Select the given topic name which belongs to the given cluster in the tree. + * + * @param clusterName the owner cluster name + * @param topicName the topic name + */ + async selectTopic(clusterName: string, topicName: string): Promise { + const clusterItem = await this.root?.findClusterItemByName(clusterName); + if (!clusterItem) { + return; + } + const topicItem = await (clusterItem).findTopictemByName(topicName); + if (!topicItem) { + return; + } + this.selectItem(topicItem); } - private getGroupChildren(clusters: Cluster[]): NodeBase[] { - return clusters.map((c) => { - return new ClusterItem(this.clientAccessor.get(c.id), c); + private selectItem(item: NodeBase): void { + this.show(); + this.tree?.reveal(item, { + select: true, + expand: true, + focus: true }); } } diff --git a/src/explorer/models/brokers.ts b/src/explorer/models/brokers.ts index 95afcf7..ae2e6ab 100644 --- a/src/explorer/models/brokers.ts +++ b/src/explorer/models/brokers.ts @@ -1,8 +1,9 @@ import * as vscode from "vscode"; -import { Broker, Client } from "../../client"; +import { Broker } from "../../client"; import { Icons } from "../../constants"; -import { ConfigsItem, ExplorerContext } from "./common"; +import { ClusterItem } from "./cluster"; +import { ConfigsItem } from "./common"; import { NodeBase } from "./nodeBase"; export class BrokerGroupItem extends NodeBase { @@ -10,24 +11,28 @@ export class BrokerGroupItem extends NodeBase { public label = "Brokers"; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - constructor(private client: Client, public clusterContext: ExplorerContext) { - super(); + constructor(parent: ClusterItem) { + super(parent); } - public async getChildren(element: NodeBase): Promise { - const brokers = await this.client.getBrokers(); + public async computeChildren(): Promise { + const client = this.getParent().client; + const brokers = await client.getBrokers(); return brokers.map((broker) => { - return new BrokerItem(this.client, broker); + return new BrokerItem(broker, this); }); } + getParent(): ClusterItem { + return super.getParent(); + } } export class BrokerItem extends NodeBase { public contextValue = "broker"; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - constructor(private client: Client, public broker: Broker) { - super(); + constructor(public broker: Broker, public brokerItem: BrokerGroupItem) { + super(brokerItem); this.label = `${broker.id} (${broker.host}:${broker.port})`; if (broker.isController) { @@ -37,8 +42,13 @@ export class BrokerItem extends NodeBase { this.iconPath = Icons.Server; } - getChildren(element: NodeBase): Promise { - const configNode = new ConfigsItem(() => this.client.getBrokerConfigs(this.broker.id)); + computeChildren(): Promise { + const client = this.getParent().getParent().client; + const configNode = new ConfigsItem(() => client.getBrokerConfigs(this.broker.id), this); return Promise.resolve([configNode]); } + + getParent(): BrokerGroupItem { + return super.getParent(); + } } diff --git a/src/explorer/models/cluster.ts b/src/explorer/models/cluster.ts index 7aeb243..27a82a7 100644 --- a/src/explorer/models/cluster.ts +++ b/src/explorer/models/cluster.ts @@ -3,27 +3,48 @@ import * as vscode from "vscode"; import { Cluster, Client } from "../../client"; import { NodeBase } from "./nodeBase"; import { BrokerGroupItem } from "./brokers"; -import { TopicGroupItem } from "./topics"; +import { TopicGroupItem, TopicItem } from "./topics"; import { ConsumerGroupsItem } from "./consumerGroups"; -import { ExplorerContext } from "./common"; +import { KafkaModel } from "./kafka"; +import { Disposable } from "vscode"; -export class ClusterItem extends NodeBase { +const TOPIC_INDEX = 1; + +export class ClusterItem extends NodeBase implements Disposable { public contextValue = "cluster"; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - public context: ExplorerContext - constructor(private client: Client, public cluster: Cluster) { - super(); + constructor(public client: Client, public cluster: Cluster, parent: KafkaModel) { + super(parent); this.label = cluster.name; this.description = cluster.bootstrap; - this.context = new ExplorerContext(cluster.id); } - async getChildren(element: NodeBase): Promise { + async computeChildren(): Promise { return [ - new BrokerGroupItem(this.client, this.context), - new TopicGroupItem(this.client, this.context), - new ConsumerGroupsItem(this.client, this.context)]; + new BrokerGroupItem(this), + new TopicGroupItem(this), + new ConsumerGroupsItem(this)]; + } + + getParent(): KafkaModel { + return super.getParent(); + } + + public dispose(): void { + this.client.dispose(); } + + async findTopictemByName(topicName: string): Promise { + const topics = (await this.getTopicGroupItem()).getChildren(); + return topics + .then(t => + t.find(child => (child).topic.id === topicName)); + } + + private async getTopicGroupItem(): Promise { + return (await this.getChildren())[TOPIC_INDEX]; + } + } diff --git a/src/explorer/models/common.ts b/src/explorer/models/common.ts index 7efbbf1..1b68e63 100644 --- a/src/explorer/models/common.ts +++ b/src/explorer/models/common.ts @@ -4,16 +4,6 @@ import { ConfigEntry } from "../../client"; import { Icons } from "../../constants"; import { NodeBase } from "./nodeBase"; - -/** - * The context for propagating information down the hierarchy. - */ -export class ExplorerContext { - constructor(public clusterId: string) { - this.clusterId = clusterId; - } -} - /** * A node used to display an error message */ @@ -22,8 +12,8 @@ export class ErrorItem extends NodeBase { public collapsibleState = vscode.TreeItemCollapsibleState.None; public iconPath = Icons.Warning; - constructor(message: string) { - super(); + constructor(message: string, parent: NodeBase) { + super(parent); this.label = message; } } @@ -36,8 +26,8 @@ export class InformationItem extends NodeBase { public collapsibleState = vscode.TreeItemCollapsibleState.None; public iconPath = Icons.Information; - constructor(message: string) { - super(); + constructor(message: string, parent: NodeBase) { + super(parent); this.label = message; } } @@ -50,15 +40,15 @@ export class ConfigsItem extends NodeBase { public contextValue = "configs"; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - constructor(private provider: () => Promise) { - super(); + constructor(private provider: () => Promise, parent: NodeBase) { + super(parent); } - async getChildren(element: NodeBase): Promise { + async computeChildren(): Promise { const configEntries = await this.provider(); return configEntries .sort((a, b) => (a.configName < b.configName ? -1 : (a.configName > b.configName) ? 1 : 0)) - .map((configEntry) => (new ConfigEntryItem(configEntry))); + .map((configEntry) => (new ConfigEntryItem(configEntry, this))); } } @@ -69,8 +59,8 @@ class ConfigEntryItem extends NodeBase { public contextValue = "configitem"; public collapsibleState = vscode.TreeItemCollapsibleState.None; - constructor(configEntry: ConfigEntry) { - super(); + constructor(configEntry: ConfigEntry, parent: NodeBase) { + super(parent); this.label = configEntry.configName; this.description = configEntry.configValue; } diff --git a/src/explorer/models/consumerGroups.ts b/src/explorer/models/consumerGroups.ts index 92ddcff..3b6a44b 100644 --- a/src/explorer/models/consumerGroups.ts +++ b/src/explorer/models/consumerGroups.ts @@ -1,23 +1,27 @@ import * as vscode from "vscode"; -import { ConsumerGroupMember, Client } from "../../client"; +import { ConsumerGroupMember } from "../../client"; import { Icons } from "../../constants"; import { NodeBase } from "./nodeBase"; -import { ExplorerContext } from "./common"; +import { ClusterItem } from "./cluster"; export class ConsumerGroupsItem extends NodeBase { public label = "Consumer Groups"; public contextValue = "consumergroups"; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - constructor(private client: Client, public context: ExplorerContext) { - super(); + constructor(parent: ClusterItem) { + super(parent); } - async getChildren(element: NodeBase): Promise { - const consumerGroupIds = await this.client.getConsumerGroupIds(); + async computeChildren() : Promise { + const client = this.getParent().client; + const consumerGroupIds = await client.getConsumerGroupIds(); return Promise.resolve( - consumerGroupIds.map((consumerGroupId) => (new ConsumerGroupItem(this.client, consumerGroupId)))); + consumerGroupIds.map((consumerGroupId) => (new ConsumerGroupItem(consumerGroupId, this)))); + } + getParent(): ClusterItem { + return super.getParent(); } } @@ -26,30 +30,35 @@ class ConsumerGroupItem extends NodeBase { public iconPath = Icons.Group; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - constructor(private client: Client, private consumerGroupId: string) { - super(); + constructor(private consumerGroupId: string, parent: ConsumerGroupsItem) { + super(parent); this.label = consumerGroupId; } - async getChildren(element: NodeBase): Promise { - const groupDetails = await this.client.getConsumerGroupDetails(this.consumerGroupId); + async computeChildren(): Promise { + const client = this.getParent().getParent().client; + const groupDetails = await client.getConsumerGroupDetails(this.consumerGroupId); return [ - new ConsumerGroupDetailsItem("State", groupDetails.state), - new ConsumerGroupMembersItem(groupDetails.members), + new ConsumerGroupDetailsItem("State", groupDetails.state, this), + new ConsumerGroupMembersItem(groupDetails.members, this), ]; } + + getParent(): ConsumerGroupsItem { + return super.getParent(); + } } class ConsumerGroupDetailsItem extends NodeBase { public contextValue = "consumergroupdetailsitem"; public collapsibleState = vscode.TreeItemCollapsibleState.None; - constructor(public label: string, description: string) { - super(); + constructor(public label: string, description: string, parent: ConsumerGroupItem) { + super(parent); this.label = label; this.description = description; } - getChildren(element: NodeBase): Promise { + computeChildren(): Promise { return Promise.resolve([]); } } @@ -59,12 +68,12 @@ class ConsumerGroupMembersItem extends NodeBase { public contextValue = "consumergroupmembersitems"; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - constructor(private members: ConsumerGroupMember[]) { - super(); + constructor(private members: ConsumerGroupMember[], parent: ConsumerGroupItem) { + super(parent); } - getChildren(element: NodeBase): Promise { - const members = this.members.map((member) => (new ConsumerGroupMemberItem(member))); + computeChildren(): Promise { + const members = this.members.map((member) => (new ConsumerGroupMemberItem(member, this))); return Promise.resolve(members); } } @@ -73,8 +82,8 @@ class ConsumerGroupMemberItem extends NodeBase { public contextValue = "consumergroupmemberitem"; public collapsibleState = vscode.TreeItemCollapsibleState.None; - constructor(member: ConsumerGroupMember) { - super(); + constructor(member: ConsumerGroupMember, parent: ConsumerGroupMembersItem) { + super(parent); this.label = `${member.clientId} (${member.clientHost})`; this.description = member.memberId; } diff --git a/src/explorer/models/kafka.ts b/src/explorer/models/kafka.ts new file mode 100644 index 0000000..270baa9 --- /dev/null +++ b/src/explorer/models/kafka.ts @@ -0,0 +1,39 @@ +import { Disposable, TreeItemCollapsibleState } from "vscode"; +import { ClientAccessor } from "../../client"; +import { ClusterSettings } from "../../settings"; +import { ClusterItem } from "./cluster"; +import { InformationItem } from "./common"; +import { NodeBase } from "./nodeBase"; + +export class KafkaModel extends NodeBase implements Disposable { + + public contextValue = ""; + public collapsibleState = TreeItemCollapsibleState.Collapsed; + + constructor( + protected clusterSettings: ClusterSettings, + protected clientAccessor: ClientAccessor) { + super(undefined); + } + + public async computeChildren(): Promise { + const clusters = this.clusterSettings.getAll(); + if (clusters.length === 0) { + return [new InformationItem("No clusters added", this)]; + } + return clusters.map((c) => { + return new ClusterItem(this.clientAccessor.get(c.id), c, this); + }); + } + + public dispose(): void { + this.children?.forEach(child => (child).dispose()); + } + + async findClusterItemByName(clusterName: string): Promise { + return this.getChildren() + .then(clusters => + clusters.find(child => (child).cluster.name === clusterName) + ); + } +} diff --git a/src/explorer/models/nodeBase.ts b/src/explorer/models/nodeBase.ts index 4023502..cf2e849 100644 --- a/src/explorer/models/nodeBase.ts +++ b/src/explorer/models/nodeBase.ts @@ -6,6 +6,11 @@ export abstract class NodeBase { public abstract collapsibleState: vscode.TreeItemCollapsibleState; public iconPath?: string | { light: string | vscode.Uri; dark: string | vscode.Uri }; public description?: string; + protected children: NodeBase[] | null = null; + + constructor(private parent: NodeBase | undefined) { + + } getTreeItem(): vscode.TreeItem { return { @@ -17,7 +22,18 @@ export abstract class NodeBase { }; } - getChildren(element: NodeBase): Promise { + computeChildren(): Promise { return Promise.resolve([]); } + + async getChildren(): Promise { + if (!this.children) { + this.children = await this.computeChildren(); + } + return this.children; + } + + getParent(): NodeBase | undefined { + return this.parent; + } } diff --git a/src/explorer/models/topics.ts b/src/explorer/models/topics.ts index 9d616a5..7be5a10 100644 --- a/src/explorer/models/topics.ts +++ b/src/explorer/models/topics.ts @@ -1,10 +1,11 @@ import * as vscode from "vscode"; -import { Topic, TopicPartition, Client } from "../../client"; +import { Topic, TopicPartition } from "../../client"; import { Icons } from "../../constants"; import { getWorkspaceSettings } from "../../settings"; import { TopicSortOption } from "../../settings/workspace"; -import { ConfigsItem, ExplorerContext } from "./common"; +import { ClusterItem } from "./cluster"; +import { ConfigsItem } from "./common"; import { NodeBase } from "./nodeBase"; export class TopicGroupItem extends NodeBase { @@ -12,13 +13,14 @@ export class TopicGroupItem extends NodeBase { public contextValue = "topics"; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - constructor(private client: Client, public context: ExplorerContext) { - super(); + constructor(parent: ClusterItem) { + super(parent); } - public async getChildren(element: NodeBase): Promise { + public async computeChildren(): Promise { + const client = this.getParent().client; const settings = getWorkspaceSettings(); - let topics = await this.client.getTopics(); + let topics = await client.getTopics(); switch (settings.topicSortOption) { case TopicSortOption.Name: @@ -30,9 +32,12 @@ export class TopicGroupItem extends NodeBase { } return topics.map((topic) => { - return new TopicItem(this.client, this.context.clusterId, topic); + return new TopicItem(topic, this); }); } + getParent(): ClusterItem { + return super.getParent(); + } private sortByNameAscending(a: Topic, b: Topic): -1 | 0 | 1 { if (a.id.toLowerCase() < b.id.toLowerCase()) { return -1; } @@ -51,20 +56,27 @@ export class TopicItem extends NodeBase { public contextValue = "topic"; public collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; public iconPath = Icons.Topic; + public clusterId: string; - constructor(private client: Client, public clusterId: string, public topic: Topic) { - super(); + constructor(public topic: Topic, parent: TopicGroupItem) { + super(parent); + this.clusterId = parent.getParent().cluster.id; this.label = topic.id; this.description = `Partitions: ${topic.partitionCount}, Replicas: ${topic.replicationFactor}`; } - async getChildren(element: NodeBase): Promise { - const configNode = new ConfigsItem(() => this.client.getTopicConfigs(this.topic.id)); + async computeChildren(): Promise { + const client = this.getParent().getParent().client; + const configNode = new ConfigsItem(() => client.getTopicConfigs(this.topic.id), this); const partitionNodes = Object.keys(this.topic.partitions).map((partition) => { - return new TopicPartitionItem(this.topic.partitions[partition]); + return new TopicPartitionItem(this.topic.partitions[partition], this); }); return Promise.resolve([configNode, ...partitionNodes]); } + + getParent(): TopicGroupItem { + return super.getParent(); + } } export class TopicPartitionItem extends NodeBase { @@ -72,8 +84,8 @@ export class TopicPartitionItem extends NodeBase { public collapsibleState = vscode.TreeItemCollapsibleState.None; public isrStatus: "in-sync" | "not-in-sync"; - constructor(partition: TopicPartition) { - super(); + constructor(partition: TopicPartition, parent: TopicItem) { + super(parent); this.label = `Partition: ${partition.partition}`; if (partition.isr.length === partition.replicas.length) { diff --git a/src/extension.ts b/src/extension.ts index 5f882d1..e35f1ee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -68,7 +68,7 @@ export function activate(context: vscode.ExtensionContext): void { handleErrors(() => Promise.resolve(explorer.refresh())))); context.subscriptions.push(vscode.commands.registerCommand( "vscode-kafka.explorer.createtopic", - handleErrors((topicGroupItem?: TopicGroupItem) => createTopicCommandHandler.execute(topicGroupItem?.context.clusterId)))); + handleErrors((topicGroupItem?: TopicGroupItem) => createTopicCommandHandler.execute(topicGroupItem?.getParent().cluster.id)))); context.subscriptions.push(vscode.commands.registerCommand( "vscode-kafka.explorer.addcluster", handleErrors(() => addClusterCommandHandler.execute()))); diff --git a/src/wizards/clusters.ts b/src/wizards/clusters.ts index b1b5d89..3d9527d 100644 --- a/src/wizards/clusters.ts +++ b/src/wizards/clusters.ts @@ -42,7 +42,7 @@ export async function addClusterWizard(clusterSettings: ClusterSettings, explore title: INPUT_TITLE, step: input.getStepNumber(), totalSteps: state.totalSteps, - value: state.name || '', + value: state.name || '', prompt: 'Friendly name', validationContext: existingClusterNames, validate: validateClusterName @@ -127,6 +127,11 @@ export async function addClusterWizard(clusterSettings: ClusterSettings, explore }); explorer.refresh(); window.showInformationMessage(`Cluster '${name}' created successfully`); + // Select the cluster in the tree must be done with a timeout + // by waiting a fix in https://github.com/microsoft/vscode/issues/114149 + setTimeout(() => { + explorer.selectClusterByName(name); + }, 1000); } catch (error) { showErrorMessage(`Error while creating cluster`, error); diff --git a/src/wizards/topics.ts b/src/wizards/topics.ts index 0f69968..036e664 100644 --- a/src/wizards/topics.ts +++ b/src/wizards/topics.ts @@ -66,6 +66,11 @@ export async function addTopicWizard(clientAccessor: ClientAccessor, clusterSett } else { explorer.refresh(); window.showInformationMessage(`Topic '${topic}' in cluster '${clusterName}' created successfully`); + // Select the topic in the tree must be done with a timeout + // by waiting a fix in https://github.com/microsoft/vscode/issues/114149 + setTimeout(() => { + explorer.selectTopic(clusterName, topic); + }, 1000); } } catch (error) { showErrorMessage(`Error while creating topic for cluster '${clusterName}'`, error);