diff --git a/extensions/ql-vscode/src/databases/config/db-config.ts b/extensions/ql-vscode/src/databases/config/db-config.ts index 1d286d451..688551cc2 100644 --- a/extensions/ql-vscode/src/databases/config/db-config.ts +++ b/extensions/ql-vscode/src/databases/config/db-config.ts @@ -10,14 +10,53 @@ export interface DbConfigDatabases { local: LocalDbConfig; } -export interface SelectedDbItem { - kind: SelectedDbItemKind; - value: string; -} +export type SelectedDbItem = + | SelectedLocalUserDefinedList + | SelectedLocalDatabase + | SelectedRemoteSystemDefinedList + | SelectedRemoteUserDefinedList + | SelectedRemoteOwner + | SelectedRemoteRepository; export enum SelectedDbItemKind { - ConfigDefined = "configDefined", + LocalUserDefinedList = "localUserDefinedList", + LocalDatabase = "localDatabase", RemoteSystemDefinedList = "remoteSystemDefinedList", + RemoteUserDefinedList = "remoteUserDefinedList", + RemoteOwner = "remoteOwner", + RemoteRepository = "remoteRepository", +} + +export interface SelectedLocalUserDefinedList { + kind: SelectedDbItemKind.LocalUserDefinedList; + listName: string; +} + +export interface SelectedLocalDatabase { + kind: SelectedDbItemKind.LocalDatabase; + databaseName: string; + listName?: string; +} + +export interface SelectedRemoteSystemDefinedList { + kind: SelectedDbItemKind.RemoteSystemDefinedList; + listName: string; +} + +export interface SelectedRemoteUserDefinedList { + kind: SelectedDbItemKind.RemoteUserDefinedList; + listName: string; +} + +export interface SelectedRemoteOwner { + kind: SelectedDbItemKind.RemoteOwner; + ownerName: string; +} + +export interface SelectedRemoteRepository { + kind: SelectedDbItemKind.RemoteRepository; + repositoryName: string; + listName?: string; } export interface RemoteDbConfig { @@ -70,10 +109,44 @@ export function cloneDbConfig(config: DbConfig): DbConfig { }, }, selected: config.selected - ? { - kind: config.selected.kind, - value: config.selected.value, - } + ? cloneDbConfigSelectedItem(config.selected) : undefined, }; } + +function cloneDbConfigSelectedItem(selected: SelectedDbItem): SelectedDbItem { + switch (selected.kind) { + case SelectedDbItemKind.LocalUserDefinedList: + return { + kind: SelectedDbItemKind.LocalUserDefinedList, + listName: selected.listName, + }; + case SelectedDbItemKind.LocalDatabase: + return { + kind: SelectedDbItemKind.LocalDatabase, + databaseName: selected.databaseName, + listName: selected.listName, + }; + case SelectedDbItemKind.RemoteSystemDefinedList: + return { + kind: SelectedDbItemKind.RemoteSystemDefinedList, + listName: selected.listName, + }; + case SelectedDbItemKind.RemoteUserDefinedList: + return { + kind: SelectedDbItemKind.RemoteUserDefinedList, + listName: selected.listName, + }; + case SelectedDbItemKind.RemoteOwner: + return { + kind: SelectedDbItemKind.RemoteOwner, + ownerName: selected.ownerName, + }; + case SelectedDbItemKind.RemoteRepository: + return { + kind: SelectedDbItemKind.RemoteRepository, + repositoryName: selected.repositoryName, + listName: selected.listName, + }; + } +} diff --git a/extensions/ql-vscode/src/databases/db-item.ts b/extensions/ql-vscode/src/databases/db-item.ts index 99bed2ce2..76bda03c6 100644 --- a/extensions/ql-vscode/src/databases/db-item.ts +++ b/extensions/ql-vscode/src/databases/db-item.ts @@ -20,12 +20,14 @@ export type LocalDbItem = LocalListDbItem | LocalDatabaseDbItem; export interface LocalListDbItem { kind: DbItemKind.LocalList; + selected: boolean; listName: string; databases: LocalDatabaseDbItem[]; } export interface LocalDatabaseDbItem { kind: DbItemKind.LocalDatabase; + selected: boolean; databaseName: string; dateAdded: number; language: string; @@ -51,6 +53,7 @@ export type RemoteDbItem = export interface RemoteSystemDefinedListDbItem { kind: DbItemKind.RemoteSystemDefinedList; + selected: boolean; listName: string; listDisplayName: string; listDescription: string; @@ -58,16 +61,66 @@ export interface RemoteSystemDefinedListDbItem { export interface RemoteUserDefinedListDbItem { kind: DbItemKind.RemoteUserDefinedList; + selected: boolean; listName: string; repos: RemoteRepoDbItem[]; } export interface RemoteOwnerDbItem { kind: DbItemKind.RemoteOwner; + selected: boolean; ownerName: string; } export interface RemoteRepoDbItem { kind: DbItemKind.RemoteRepo; + selected: boolean; repoFullName: string; } + +export function isRemoteSystemDefinedListDbItem( + dbItem: DbItem, +): dbItem is RemoteSystemDefinedListDbItem { + return dbItem.kind === DbItemKind.RemoteSystemDefinedList; +} + +export function isRemoteUserDefinedListDbItem( + dbItem: DbItem, +): dbItem is RemoteUserDefinedListDbItem { + return dbItem.kind === DbItemKind.RemoteUserDefinedList; +} + +export function isRemoteOwnerDbItem( + dbItem: DbItem, +): dbItem is RemoteOwnerDbItem { + return dbItem.kind === DbItemKind.RemoteOwner; +} + +export function isRemoteRepoDbItem(dbItem: DbItem): dbItem is RemoteRepoDbItem { + return dbItem.kind === DbItemKind.RemoteRepo; +} + +export function isLocalListDbItem(dbItem: DbItem): dbItem is LocalListDbItem { + return dbItem.kind === DbItemKind.LocalList; +} + +export function isLocalDatabaseDbItem( + dbItem: DbItem, +): dbItem is LocalDatabaseDbItem { + return dbItem.kind === DbItemKind.LocalDatabase; +} + +export type SelectableDbItem = RemoteDbItem | LocalDbItem; + +export function isSelectableDbItem(dbItem: DbItem): dbItem is SelectableDbItem { + return SelectableDbItemKinds.includes(dbItem.kind); +} + +const SelectableDbItemKinds = [ + DbItemKind.LocalList, + DbItemKind.LocalDatabase, + DbItemKind.RemoteSystemDefinedList, + DbItemKind.RemoteUserDefinedList, + DbItemKind.RemoteOwner, + DbItemKind.RemoteRepo, +]; diff --git a/extensions/ql-vscode/src/databases/db-module.ts b/extensions/ql-vscode/src/databases/db-module.ts index 95348525a..ed7bef0d2 100644 --- a/extensions/ql-vscode/src/databases/db-module.ts +++ b/extensions/ql-vscode/src/databases/db-module.ts @@ -1,3 +1,4 @@ +import { window } from "vscode"; import { App, AppMode } from "../common/app"; import { isCanary, isNewQueryRunExperienceEnabled } from "../config"; import { extLogger } from "../common"; @@ -5,6 +6,7 @@ import { DisposableObject } from "../pure/disposable-object"; import { DbConfigStore } from "./config/db-config-store"; import { DbManager } from "./db-manager"; import { DbPanel } from "./ui/db-panel"; +import { DbSelectionDecorationProvider } from "./ui/db-selection-decoration-provider"; export class DbModule extends DisposableObject { public async initialize(app: App): Promise { @@ -30,6 +32,10 @@ export class DbModule extends DisposableObject { this.push(dbPanel); this.push(dbConfigStore); + + const dbSelectionDecorationProvider = new DbSelectionDecorationProvider(); + + window.registerFileDecorationProvider(dbSelectionDecorationProvider); } } diff --git a/extensions/ql-vscode/src/databases/db-tree-creator.ts b/extensions/ql-vscode/src/databases/db-tree-creator.ts index e6999a9ca..28cabbecf 100644 --- a/extensions/ql-vscode/src/databases/db-tree-creator.ts +++ b/extensions/ql-vscode/src/databases/db-tree-creator.ts @@ -3,6 +3,7 @@ import { LocalDatabase, LocalList, RemoteRepositoryList, + SelectedDbItemKind, } from "./config/db-config"; import { DbItemKind, @@ -18,16 +19,20 @@ import { export function createRemoteTree(dbConfig: DbConfig): RootRemoteDbItem { const systemDefinedLists = [ - createSystemDefinedList(10), - createSystemDefinedList(100), - createSystemDefinedList(1000), + createSystemDefinedList(10, dbConfig), + createSystemDefinedList(100, dbConfig), + createSystemDefinedList(1000, dbConfig), ]; const userDefinedRepoLists = dbConfig.databases.remote.repositoryLists.map( - createUserDefinedList, + (r) => createRemoteUserDefinedList(r, dbConfig), + ); + const owners = dbConfig.databases.remote.owners.map((o) => + createOwnerItem(o, dbConfig), + ); + const repos = dbConfig.databases.remote.repositories.map((r) => + createRepoItem(r, dbConfig), ); - const owners = dbConfig.databases.remote.owners.map(createOwnerItem); - const repos = dbConfig.databases.remote.repositories.map(createRepoItem); return { kind: DbItemKind.RootRemote, @@ -41,8 +46,12 @@ export function createRemoteTree(dbConfig: DbConfig): RootRemoteDbItem { } export function createLocalTree(dbConfig: DbConfig): RootLocalDbItem { - const localLists = dbConfig.databases.local.lists.map(createLocalList); - const localDbs = dbConfig.databases.local.databases.map(createLocalDb); + const localLists = dbConfig.databases.local.lists.map((l) => + createLocalList(l, dbConfig), + ); + const localDbs = dbConfig.databases.local.databases.map((l) => + createLocalDb(l, dbConfig), + ); return { kind: DbItemKind.RootLocal, @@ -50,53 +59,105 @@ export function createLocalTree(dbConfig: DbConfig): RootLocalDbItem { }; } -function createSystemDefinedList(n: number): RemoteSystemDefinedListDbItem { +function createSystemDefinedList( + n: number, + dbConfig: DbConfig, +): RemoteSystemDefinedListDbItem { + const listName = `top_${n}`; + + const selected = + dbConfig.selected && + dbConfig.selected.kind === SelectedDbItemKind.RemoteSystemDefinedList && + dbConfig.selected.listName === listName; + return { kind: DbItemKind.RemoteSystemDefinedList, - listName: `top_${n}`, + listName, listDisplayName: `Top ${n} repositories`, listDescription: `Top ${n} repositories of a language`, + selected: !!selected, }; } -function createUserDefinedList( +function createRemoteUserDefinedList( list: RemoteRepositoryList, + dbConfig: DbConfig, ): RemoteUserDefinedListDbItem { + const selected = + dbConfig.selected && + dbConfig.selected.kind === SelectedDbItemKind.RemoteUserDefinedList && + dbConfig.selected.listName === list.name; + return { kind: DbItemKind.RemoteUserDefinedList, listName: list.name, - repos: list.repositories.map((r) => createRepoItem(r)), + repos: list.repositories.map((r) => createRepoItem(r, dbConfig, list.name)), + selected: !!selected, }; } -function createOwnerItem(owner: string): RemoteOwnerDbItem { +function createOwnerItem(owner: string, dbConfig: DbConfig): RemoteOwnerDbItem { + const selected = + dbConfig.selected && + dbConfig.selected.kind === SelectedDbItemKind.RemoteOwner && + dbConfig.selected.ownerName === owner; + return { kind: DbItemKind.RemoteOwner, ownerName: owner, + selected: !!selected, }; } -function createRepoItem(repo: string): RemoteRepoDbItem { +function createRepoItem( + repo: string, + dbConfig: DbConfig, + listName?: string, +): RemoteRepoDbItem { + const selected = + dbConfig.selected && + dbConfig.selected.kind === SelectedDbItemKind.RemoteRepository && + dbConfig.selected.repositoryName === repo && + dbConfig.selected.listName === listName; + return { kind: DbItemKind.RemoteRepo, repoFullName: repo, + selected: !!selected, }; } -function createLocalList(list: LocalList): LocalListDbItem { +function createLocalList(list: LocalList, dbConfig: DbConfig): LocalListDbItem { + const selected = + dbConfig.selected && + dbConfig.selected.kind === SelectedDbItemKind.LocalUserDefinedList && + dbConfig.selected.listName === list.name; + return { kind: DbItemKind.LocalList, listName: list.name, - databases: list.databases.map(createLocalDb), + databases: list.databases.map((d) => createLocalDb(d, dbConfig, list.name)), + selected: !!selected, }; } -function createLocalDb(db: LocalDatabase): LocalDatabaseDbItem { +function createLocalDb( + db: LocalDatabase, + dbConfig: DbConfig, + listName?: string, +): LocalDatabaseDbItem { + const selected = + dbConfig.selected && + dbConfig.selected.kind === SelectedDbItemKind.LocalDatabase && + dbConfig.selected.databaseName === db.name && + dbConfig.selected.listName === listName; + return { kind: DbItemKind.LocalDatabase, databaseName: db.name, dateAdded: db.dateAdded, language: db.language, storagePath: db.storagePath, + selected: !!selected, }; } diff --git a/extensions/ql-vscode/src/databases/ui/db-selection-decoration-provider.ts b/extensions/ql-vscode/src/databases/ui/db-selection-decoration-provider.ts new file mode 100644 index 000000000..1a8d45d75 --- /dev/null +++ b/extensions/ql-vscode/src/databases/ui/db-selection-decoration-provider.ts @@ -0,0 +1,22 @@ +import { + CancellationToken, + FileDecoration, + FileDecorationProvider, + ProviderResult, + Uri, +} from "vscode"; + +export class DbSelectionDecorationProvider implements FileDecorationProvider { + provideFileDecoration( + uri: Uri, + _token: CancellationToken, + ): ProviderResult { + if (uri?.query === "selected=true") { + return { + badge: "✔", + }; + } + + return undefined; + } +} diff --git a/extensions/ql-vscode/src/databases/ui/db-tree-view-item.ts b/extensions/ql-vscode/src/databases/ui/db-tree-view-item.ts index a63ff851f..67cb8edf3 100644 --- a/extensions/ql-vscode/src/databases/ui/db-tree-view-item.ts +++ b/extensions/ql-vscode/src/databases/ui/db-tree-view-item.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { DbItem, + isSelectableDbItem, LocalDatabaseDbItem, LocalListDbItem, RemoteOwnerDbItem, @@ -28,6 +29,16 @@ export class DbTreeViewItem extends vscode.TreeItem { public readonly children: DbTreeViewItem[], ) { super(label, collapsibleState); + + if (dbItem && isSelectableDbItem(dbItem)) { + if (dbItem.selected) { + // Define the resource id to drive the UI to render this item as selected. + this.resourceUri = vscode.Uri.parse("codeql://databases?selected=true"); + } else { + // Define a context value to drive the UI to show an action to select the item. + this.contextValue = "selectableDbItem"; + } + } } } diff --git a/extensions/ql-vscode/src/remote-queries/export-results.ts b/extensions/ql-vscode/src/remote-queries/export-results.ts index ed004f0cd..1641269d4 100644 --- a/extensions/ql-vscode/src/remote-queries/export-results.ts +++ b/extensions/ql-vscode/src/remote-queries/export-results.ts @@ -2,13 +2,13 @@ import { join } from "path"; import { ensureDir, writeFile } from "fs-extra"; import { - window, commands, - Uri, - ExtensionContext, - workspace, - ViewColumn, CancellationToken, + ExtensionContext, + Uri, + ViewColumn, + window, + workspace, } from "vscode"; import { Credentials } from "../authentication"; import { ProgressCallback, UserCancellationException } from "../commandRunner"; @@ -21,6 +21,7 @@ import { generateMarkdown, generateVariantAnalysisMarkdown, MarkdownFile, + RepositorySummary, } from "./remote-queries-markdown-generation"; import { RemoteQuery } from "./remote-query"; import { AnalysisResults, sumAnalysesResults } from "./shared/analysis-result"; @@ -30,6 +31,7 @@ import { assertNever } from "../pure/helpers-pure"; import { VariantAnalysis, VariantAnalysisScannedRepository, + VariantAnalysisScannedRepositoryDownloadStatus, VariantAnalysisScannedRepositoryResult, } from "./shared/variant-analysis"; import { @@ -162,6 +164,10 @@ export async function exportVariantAnalysisResults( throw new UserCancellationException("Cancelled"); } + const repoStates = await variantAnalysisManager.getRepoStates( + variantAnalysisId, + ); + void extLogger.log( `Exporting variant analysis results for variant analysis with id ${variantAnalysis.id}`, ); @@ -197,6 +203,18 @@ export async function exportVariantAnalysisResults( } for (const repo of repositories) { + const repoState = repoStates.find( + (r) => r.repositoryId === repo.repository.id, + ); + + // Do not export if it has not yet completed or the download has not yet succeeded. + if ( + repoState?.downloadStatus !== + VariantAnalysisScannedRepositoryDownloadStatus.Succeeded + ) { + continue; + } + if (repo.resultCount == 0) { yield [ repo, @@ -268,11 +286,14 @@ export async function exportVariantAnalysisAnalysisResults( message: "Generating Markdown files", }); - const description = buildVariantAnalysisGistDescription(variantAnalysis); - const markdownFiles = await generateVariantAnalysisMarkdown( + const { markdownFiles, summaries } = await generateVariantAnalysisMarkdown( variantAnalysis, analysesResults, - "gist", + exportFormat, + ); + const description = buildVariantAnalysisGistDescription( + variantAnalysis, + summaries, ); await exportResults( @@ -407,20 +428,16 @@ const buildGistDescription = ( */ const buildVariantAnalysisGistDescription = ( variantAnalysis: VariantAnalysis, + summaries: RepositorySummary[], ) => { - const resultCount = - variantAnalysis.scannedRepos?.reduce( - (acc, item) => acc + (item.resultCount ?? 0), - 0, - ) ?? 0; + const resultCount = summaries.reduce( + (acc, summary) => acc + (summary.resultCount ?? 0), + 0, + ); const resultLabel = pluralize(resultCount, "result", "results"); - const repositoryLabel = variantAnalysis.scannedRepos?.length - ? `(${pluralize( - variantAnalysis.scannedRepos.length, - "repository", - "repositories", - )})` + const repositoryLabel = summaries.length + ? `(${pluralize(summaries.length, "repository", "repositories")})` : ""; return `${variantAnalysis.query.name} (${variantAnalysis.query.language}) ${resultLabel} ${repositoryLabel}`; }; diff --git a/extensions/ql-vscode/src/remote-queries/remote-queries-markdown-generation.ts b/extensions/ql-vscode/src/remote-queries/remote-queries-markdown-generation.ts index 4cf6f8630..c013bca36 100644 --- a/extensions/ql-vscode/src/remote-queries/remote-queries-markdown-generation.ts +++ b/extensions/ql-vscode/src/remote-queries/remote-queries-markdown-generation.ts @@ -18,6 +18,7 @@ import { VariantAnalysisScannedRepository, VariantAnalysisScannedRepositoryResult, } from "./shared/variant-analysis"; +import { RepositoryWithMetadata } from "./shared/repository"; export type MarkdownLinkType = "local" | "gist"; @@ -74,6 +75,17 @@ export function generateMarkdown( return [summaryFile, ...resultsFiles]; } +export interface RepositorySummary { + fileName: string; + repository: RepositoryWithMetadata; + resultCount: number; +} + +export interface VariantAnalysisMarkdown { + markdownFiles: MarkdownFile[]; + summaries: RepositorySummary[]; +} + /** * Generates markdown files with variant analysis results. */ @@ -83,23 +95,22 @@ export async function generateVariantAnalysisMarkdown( [VariantAnalysisScannedRepository, VariantAnalysisScannedRepositoryResult] >, linkType: MarkdownLinkType, -): Promise { +): Promise { const resultsFiles: MarkdownFile[] = []; - // Generate summary file with links to individual files - const summaryFile: MarkdownFile = - generateVariantAnalysisMarkdownSummary(variantAnalysis); + const summaries: RepositorySummary[] = []; for await (const [scannedRepo, result] of results) { - if (scannedRepo.resultCount === 0) { + if (!scannedRepo.resultCount || scannedRepo.resultCount === 0) { continue; } // Append nwo and results count to the summary table const fullName = scannedRepo.repository.fullName; const fileName = createFileName(fullName); - const link = createRelativeLink(fileName, linkType); - summaryFile.content.push( - `| ${fullName} | [${scannedRepo.resultCount} result(s)](${link}) |`, - ); + summaries.push({ + fileName, + repository: scannedRepo.repository, + resultCount: scannedRepo.resultCount, + }); // Generate individual markdown file for each repository const resultsFileContent = [`### ${scannedRepo.repository.fullName}`, ""]; @@ -121,7 +132,18 @@ export async function generateVariantAnalysisMarkdown( content: resultsFileContent, }); } - return [summaryFile, ...resultsFiles]; + + // Generate summary file with links to individual files + const summaryFile: MarkdownFile = generateVariantAnalysisMarkdownSummary( + variantAnalysis, + summaries, + linkType, + ); + + return { + markdownFiles: [summaryFile, ...resultsFiles], + summaries, + }; } export function generateMarkdownSummary(query: RemoteQuery): MarkdownFile { @@ -147,6 +169,8 @@ export function generateMarkdownSummary(query: RemoteQuery): MarkdownFile { export function generateVariantAnalysisMarkdownSummary( variantAnalysis: VariantAnalysis, + summaries: RepositorySummary[], + linkType: MarkdownLinkType, ): MarkdownFile { const lines: string[] = []; // Title @@ -165,7 +189,14 @@ export function generateVariantAnalysisMarkdownSummary( // Summary table lines.push("### Summary", "", "| Repository | Results |", "| --- | --- |"); - // nwo and result count will be appended to this table + + for (const summary of summaries) { + // Append nwo and results count to the summary table + const fullName = summary.repository.fullName; + const link = createRelativeLink(summary.fileName, linkType); + lines.push(`| ${fullName} | [${summary.resultCount} result(s)](${link}) |`); + } + return { fileName: "_summary", content: lines, diff --git a/extensions/ql-vscode/src/remote-queries/variant-analysis-monitor.ts b/extensions/ql-vscode/src/remote-queries/variant-analysis-monitor.ts index a0a0ab947..ba528de15 100644 --- a/extensions/ql-vscode/src/remote-queries/variant-analysis-monitor.ts +++ b/extensions/ql-vscode/src/remote-queries/variant-analysis-monitor.ts @@ -13,9 +13,12 @@ import { VariantAnalysis, VariantAnalysisScannedRepository, } from "./shared/variant-analysis"; +import { VariantAnalysis as ApiVariantAnalysis } from "./gh-api/variant-analysis"; import { processUpdatedVariantAnalysis } from "./variant-analysis-processor"; import { DisposableObject } from "../pure/disposable-object"; import { sleep } from "../pure/time"; +import { getErrorMessage } from "../pure/helpers-pure"; +import { showAndLogWarningMessage } from "../helpers"; export class VariantAnalysisMonitor extends DisposableObject { // With a sleep of 5 seconds, the maximum number of attempts takes @@ -60,11 +63,19 @@ export class VariantAnalysisMonitor extends DisposableObject { return; } - const variantAnalysisSummary = await getVariantAnalysis( - credentials, - variantAnalysis.controllerRepo.id, - variantAnalysis.id, - ); + let variantAnalysisSummary: ApiVariantAnalysis; + try { + variantAnalysisSummary = await getVariantAnalysis( + credentials, + variantAnalysis.controllerRepo.id, + variantAnalysis.id, + ); + } catch (e) { + void showAndLogWarningMessage( + `Error while monitoring variant analysis: ${getErrorMessage(e)}`, + ); + continue; + } variantAnalysis = processUpdatedVariantAnalysis( variantAnalysis, diff --git a/extensions/ql-vscode/src/remote-queries/variant-analysis-view-manager.ts b/extensions/ql-vscode/src/remote-queries/variant-analysis-view-manager.ts index 28a75b8c9..7f307c42d 100644 --- a/extensions/ql-vscode/src/remote-queries/variant-analysis-view-manager.ts +++ b/extensions/ql-vscode/src/remote-queries/variant-analysis-view-manager.ts @@ -13,6 +13,7 @@ export interface VariantAnalysisViewManager< > { registerView(view: T): void; unregisterView(view: T): void; + getView(variantAnalysisId: number): T | undefined; getVariantAnalysis( variantAnalysisId: number, diff --git a/extensions/ql-vscode/src/remote-queries/variant-analysis-view-serializer.ts b/extensions/ql-vscode/src/remote-queries/variant-analysis-view-serializer.ts index fb00587e5..2eeb81793 100644 --- a/extensions/ql-vscode/src/remote-queries/variant-analysis-view-serializer.ts +++ b/extensions/ql-vscode/src/remote-queries/variant-analysis-view-serializer.ts @@ -38,6 +38,15 @@ export class VariantAnalysisViewSerializer implements WebviewPanelSerializer { const manager = await this.waitForExtensionFullyLoaded(); + const existingView = manager.getView( + variantAnalysisState.variantAnalysisId, + ); + if (existingView) { + await existingView.openView(); + webviewPanel.dispose(); + return; + } + const view = new VariantAnalysisView( this.ctx, variantAnalysisState.variantAnalysisId, diff --git a/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysis.stories.tsx b/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysis.stories.tsx index 505c9553e..a28b122d7 100644 --- a/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysis.stories.tsx +++ b/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysis.stories.tsx @@ -43,6 +43,7 @@ const variantAnalysis: VariantAnalysisDomainModel = { private: false, }, analysisStatus: VariantAnalysisRepoStatus.Succeeded, + resultCount: 100, }, { repository: { diff --git a/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysisActions.stories.tsx b/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysisActions.stories.tsx index 2def22117..3b966a304 100644 --- a/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysisActions.stories.tsx +++ b/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysisActions.stories.tsx @@ -47,10 +47,24 @@ InProgress.args = { variantAnalysisStatus: VariantAnalysisStatus.InProgress, }; +export const InProgressWithResults = Template.bind({}); +InProgressWithResults.args = { + variantAnalysisStatus: VariantAnalysisStatus.InProgress, + showResultActions: true, +}; + +export const InProgressWithoutDownloadedRepos = Template.bind({}); +InProgressWithoutDownloadedRepos.args = { + variantAnalysisStatus: VariantAnalysisStatus.InProgress, + showResultActions: true, + exportResultsDisabled: true, +}; + export const Succeeded = Template.bind({}); Succeeded.args = { ...InProgress.args, variantAnalysisStatus: VariantAnalysisStatus.Succeeded, + showResultActions: true, }; export const Failed = Template.bind({}); diff --git a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx index 82f74c734..023f2d80a 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx @@ -144,6 +144,9 @@ export function VariantAnalysis({ <> void; stopQueryDisabled?: boolean; + showResultActions?: boolean; onCopyRepositoryListClick: () => void; onExportResultsClick: () => void; + copyRepositoryListDisabled?: boolean; + exportResultsDisabled?: boolean; }; const Container = styled.div` @@ -26,12 +29,33 @@ const Button = styled(VSCodeButton)` export const VariantAnalysisActions = ({ variantAnalysisStatus, onStopQueryClick, + stopQueryDisabled, + showResultActions, onCopyRepositoryListClick, onExportResultsClick, - stopQueryDisabled, -}: Props) => { + copyRepositoryListDisabled, + exportResultsDisabled, +}: VariantAnalysisActionsProps) => { return ( + {showResultActions && ( + <> + + + + )} {variantAnalysisStatus === VariantAnalysisStatus.InProgress && ( - - - )} ); }; diff --git a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisHeader.tsx b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisHeader.tsx index ed0d5b8d4..ec3fd41b1 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisHeader.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisHeader.tsx @@ -6,15 +6,25 @@ import { getTotalResultCount, hasRepoScanCompleted, VariantAnalysis, + VariantAnalysisScannedRepositoryDownloadStatus, + VariantAnalysisScannedRepositoryState, } from "../../remote-queries/shared/variant-analysis"; import { QueryDetails } from "./QueryDetails"; import { VariantAnalysisActions } from "./VariantAnalysisActions"; import { VariantAnalysisStats } from "./VariantAnalysisStats"; import { parseDate } from "../../pure/date"; import { basename } from "../common/path"; +import { + defaultFilterSortState, + filterAndSortRepositoriesWithResults, + RepositoriesFilterSortState, +} from "../../pure/variant-analysis-filter-sort"; export type VariantAnalysisHeaderProps = { variantAnalysis: VariantAnalysis; + repositoryStates?: VariantAnalysisScannedRepositoryState[]; + filterSortState?: RepositoriesFilterSortState; + selectedRepositoryIds?: number[]; onOpenQueryFileClick: () => void; onViewQueryTextClick: () => void; @@ -40,6 +50,9 @@ const Row = styled.div` export const VariantAnalysisHeader = ({ variantAnalysis, + repositoryStates, + filterSortState, + selectedRepositoryIds, onOpenQueryFileClick, onViewQueryTextClick, onStopQueryClick, @@ -62,6 +75,36 @@ export const VariantAnalysisHeader = ({ const hasSkippedRepos = useMemo(() => { return getSkippedRepoCount(variantAnalysis.skippedRepos) > 0; }, [variantAnalysis.skippedRepos]); + const filteredRepositories = useMemo(() => { + return filterAndSortRepositoriesWithResults(variantAnalysis.scannedRepos, { + ...defaultFilterSortState, + ...filterSortState, + repositoryIds: selectedRepositoryIds, + }); + }, [filterSortState, selectedRepositoryIds, variantAnalysis.scannedRepos]); + const hasDownloadedRepos = useMemo(() => { + const repositoryStatesById = new Map< + number, + VariantAnalysisScannedRepositoryState + >(); + if (repositoryStates) { + for (const repositoryState of repositoryStates) { + repositoryStatesById.set(repositoryState.repositoryId, repositoryState); + } + } + + return filteredRepositories?.some((repo) => { + return ( + repositoryStatesById.get(repo.repository.id)?.downloadStatus === + VariantAnalysisScannedRepositoryDownloadStatus.Succeeded + ); + }); + }, [repositoryStates, filteredRepositories]); + const hasReposWithResults = useMemo(() => { + return filteredRepositories?.some( + (repo) => repo.resultCount && repo.resultCount > 0, + ); + }, [filteredRepositories]); return ( @@ -74,10 +117,13 @@ export const VariantAnalysisHeader = ({ /> 0} onStopQueryClick={onStopQueryClick} onCopyRepositoryListClick={onCopyRepositoryListClick} onExportResultsClick={onExportResultsClick} stopQueryDisabled={!variantAnalysis.actionsWorkflowRunId} + exportResultsDisabled={!hasDownloadedRepos} + copyRepositoryListDisabled={!hasReposWithResults} /> { const onStopQueryClick = jest.fn(); @@ -15,51 +18,78 @@ describe(VariantAnalysisActions.name, () => { onExportResultsClick.mockReset(); }); - const render = (variantAnalysisStatus: VariantAnalysisStatus) => + const render = ( + props: Pick & + Partial, + ) => reactRender( , ); it("renders 1 button when in progress", async () => { - const { container } = render(VariantAnalysisStatus.InProgress); + const { container } = render({ + variantAnalysisStatus: VariantAnalysisStatus.InProgress, + }); expect(container.querySelectorAll("vscode-button").length).toEqual(1); }); it("renders the stop query button when in progress", async () => { - render(VariantAnalysisStatus.InProgress); + render({ + variantAnalysisStatus: VariantAnalysisStatus.InProgress, + }); await userEvent.click(screen.getByText("Stop query")); expect(onStopQueryClick).toHaveBeenCalledTimes(1); }); + it("renders 3 buttons when in progress with results", async () => { + const { container } = render({ + variantAnalysisStatus: VariantAnalysisStatus.InProgress, + showResultActions: true, + }); + + expect(container.querySelectorAll("vscode-button").length).toEqual(3); + }); + it("renders 2 buttons when succeeded", async () => { - const { container } = render(VariantAnalysisStatus.Succeeded); + const { container } = render({ + variantAnalysisStatus: VariantAnalysisStatus.Succeeded, + showResultActions: true, + }); expect(container.querySelectorAll("vscode-button").length).toEqual(2); }); it("renders the copy repository list button when succeeded", async () => { - render(VariantAnalysisStatus.Succeeded); + render({ + variantAnalysisStatus: VariantAnalysisStatus.Succeeded, + showResultActions: true, + }); await userEvent.click(screen.getByText("Copy repository list")); expect(onCopyRepositoryListClick).toHaveBeenCalledTimes(1); }); it("renders the export results button when succeeded", async () => { - render(VariantAnalysisStatus.Succeeded); + render({ + variantAnalysisStatus: VariantAnalysisStatus.Succeeded, + showResultActions: true, + }); await userEvent.click(screen.getByText("Export results")); expect(onExportResultsClick).toHaveBeenCalledTimes(1); }); it("does not render any buttons when failed", () => { - const { container } = render(VariantAnalysisStatus.Failed); + const { container } = render({ + variantAnalysisStatus: VariantAnalysisStatus.Failed, + }); expect(container.querySelectorAll("vscode-button").length).toEqual(0); }); diff --git a/extensions/ql-vscode/src/vscode-tests/minimal-workspace/databases/db-panel.test.ts b/extensions/ql-vscode/src/vscode-tests/minimal-workspace/databases/db-panel.test.ts index defa374cf..f869b8b67 100644 --- a/extensions/ql-vscode/src/vscode-tests/minimal-workspace/databases/db-panel.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/minimal-workspace/databases/db-panel.test.ts @@ -1,7 +1,10 @@ import { TreeItemCollapsibleState, ThemeIcon } from "vscode"; import { join } from "path"; import { ensureDir, remove, writeJson } from "fs-extra"; -import { DbConfig } from "../../../databases/config/db-config"; +import { + DbConfig, + SelectedDbItemKind, +} from "../../../databases/config/db-config"; import { DbManager } from "../../../databases/db-manager"; import { DbConfigStore } from "../../../databases/config/db-config-store"; import { DbTreeDataProvider } from "../../../databases/ui/db-tree-data-provider"; @@ -307,6 +310,7 @@ describe("db panel", () => { dateAdded: 1668428293677, language: "cpp", storagePath: "/path/to/db1/", + selected: false, }, { kind: DbItemKind.LocalDatabase, @@ -314,6 +318,7 @@ describe("db panel", () => { dateAdded: 1668428472731, language: "cpp", storagePath: "/path/to/db2/", + selected: false, }, ]); checkLocalListItem(localListItems[1], "my-list-2", [ @@ -323,6 +328,7 @@ describe("db panel", () => { dateAdded: 1668428472731, language: "ruby", storagePath: "/path/to/db3/", + selected: false, }, ]); }); @@ -381,6 +387,7 @@ describe("db panel", () => { dateAdded: 1668428293677, language: "csharp", storagePath: "/path/to/db1/", + selected: false, }); checkLocalDatabaseItem(localDatabaseItems[1], { kind: DbItemKind.LocalDatabase, @@ -388,9 +395,134 @@ describe("db panel", () => { dateAdded: 1668428472731, language: "go", storagePath: "/path/to/db2/", + selected: false, }); }); + it("should mark selected remote db list as selected", async () => { + const dbConfig: DbConfig = { + databases: { + remote: { + repositoryLists: [ + { + name: "my-list-1", + repositories: ["owner1/repo1", "owner1/repo2"], + }, + { + name: "my-list-2", + repositories: ["owner2/repo1", "owner2/repo2"], + }, + ], + owners: [], + repositories: [], + }, + local: { + lists: [], + databases: [], + }, + }, + selected: { + kind: SelectedDbItemKind.RemoteUserDefinedList, + listName: "my-list-2", + }, + }; + + await saveDbConfig(dbConfig); + + const dbTreeItems = await dbTreeDataProvider.getChildren(); + + expect(dbTreeItems).toBeTruthy(); + const items = dbTreeItems!; + + const remoteRootNode = items[0]; + expect(remoteRootNode.dbItem).toBeTruthy(); + expect(remoteRootNode.dbItem?.kind).toEqual(DbItemKind.RootRemote); + + const list1 = remoteRootNode.children.find( + (c) => + c.dbItem?.kind === DbItemKind.RemoteUserDefinedList && + c.dbItem?.listName === "my-list-1", + ); + const list2 = remoteRootNode.children.find( + (c) => + c.dbItem?.kind === DbItemKind.RemoteUserDefinedList && + c.dbItem?.listName === "my-list-2", + ); + + expect(list1).toBeTruthy(); + expect(list2).toBeTruthy(); + expect(isTreeViewItemSelectable(list1!)).toBeTruthy(); + expect(isTreeViewItemSelected(list2!)).toBeTruthy(); + }); + + it("should mark selected remote db inside list as selected", async () => { + const dbConfig: DbConfig = { + databases: { + remote: { + repositoryLists: [ + { + name: "my-list-1", + repositories: ["owner1/repo1", "owner1/repo2"], + }, + { + name: "my-list-2", + repositories: ["owner1/repo1", "owner2/repo2"], + }, + ], + owners: [], + repositories: ["owner1/repo1"], + }, + local: { + lists: [], + databases: [], + }, + }, + selected: { + kind: SelectedDbItemKind.RemoteRepository, + repositoryName: "owner1/repo1", + listName: "my-list-2", + }, + }; + + await saveDbConfig(dbConfig); + + const dbTreeItems = await dbTreeDataProvider.getChildren(); + + expect(dbTreeItems).toBeTruthy(); + const items = dbTreeItems!; + + const remoteRootNode = items[0]; + expect(remoteRootNode.dbItem).toBeTruthy(); + expect(remoteRootNode.dbItem?.kind).toEqual(DbItemKind.RootRemote); + + const list2 = remoteRootNode.children.find( + (c) => + c.dbItem?.kind === DbItemKind.RemoteUserDefinedList && + c.dbItem?.listName === "my-list-2", + ); + expect(list2).toBeTruthy(); + + const repo1Node = list2?.children.find( + (c) => + c.dbItem?.kind === DbItemKind.RemoteRepo && + c.dbItem?.repoFullName === "owner1/repo1", + ); + expect(repo1Node).toBeTruthy(); + expect(isTreeViewItemSelected(repo1Node!)).toBeTruthy(); + + const repo2Node = list2?.children.find( + (c) => + c.dbItem?.kind === DbItemKind.RemoteRepo && + c.dbItem?.repoFullName === "owner2/repo2", + ); + expect(repo2Node).toBeTruthy(); + expect(isTreeViewItemSelectable(repo2Node!)).toBeTruthy(); + + for (const item of remoteRootNode.children) { + expect(isTreeViewItemSelectable(item)).toBeTruthy(); + } + }); + async function saveDbConfig(dbConfig: DbConfig): Promise { await writeJson(dbConfigFilePath, dbConfig); @@ -471,4 +603,18 @@ describe("db panel", () => { expect(item.iconPath).toEqual(new ThemeIcon("database")); expect(item.collapsibleState).toBe(TreeItemCollapsibleState.None); } + + function isTreeViewItemSelectable(treeViewItem: DbTreeViewItem) { + return ( + treeViewItem.resourceUri === undefined && + treeViewItem.contextValue === "selectableDbItem" + ); + } + + function isTreeViewItemSelected(treeViewItem: DbTreeViewItem) { + return ( + treeViewItem.resourceUri?.query === "selected=true" && + treeViewItem.contextValue === undefined + ); + } }); diff --git a/extensions/ql-vscode/test/pure-tests/databases/config/data/workspace-databases.json b/extensions/ql-vscode/test/pure-tests/databases/config/data/workspace-databases.json index 0d1a7b1ea..5264d3805 100644 --- a/extensions/ql-vscode/test/pure-tests/databases/config/data/workspace-databases.json +++ b/extensions/ql-vscode/test/pure-tests/databases/config/data/workspace-databases.json @@ -46,7 +46,7 @@ } }, "selected": { - "kind": "configDefined", - "value": "path.to.database" + "kind": "remoteUserDefinedList", + "listName": "repoList1" } } diff --git a/extensions/ql-vscode/test/pure-tests/databases/config/db-config-store.test.ts b/extensions/ql-vscode/test/pure-tests/databases/config/db-config-store.test.ts index adacaa791..8f3c3c8b5 100644 --- a/extensions/ql-vscode/test/pure-tests/databases/config/db-config-store.test.ts +++ b/extensions/ql-vscode/test/pure-tests/databases/config/db-config-store.test.ts @@ -84,8 +84,8 @@ describe("db config store", () => { storagePath: "/path/to/database/", }); expect(config.selected).toEqual({ - kind: "configDefined", - value: "path.to.database", + kind: "remoteUserDefinedList", + listName: "repoList1", }); configStore.dispose(); diff --git a/extensions/ql-vscode/test/pure-tests/databases/db-tree-creator.test.ts b/extensions/ql-vscode/test/pure-tests/databases/db-tree-creator.test.ts index 4ee36e8c2..142012709 100644 --- a/extensions/ql-vscode/test/pure-tests/databases/db-tree-creator.test.ts +++ b/extensions/ql-vscode/test/pure-tests/databases/db-tree-creator.test.ts @@ -1,5 +1,13 @@ -import { DbConfig } from "../../../src/databases/config/db-config"; -import { DbItemKind } from "../../../src/databases/db-item"; +import { + DbConfig, + SelectedDbItemKind, +} from "../../../src/databases/config/db-config"; +import { + DbItemKind, + isRemoteOwnerDbItem, + isRemoteRepoDbItem, + isRemoteUserDefinedListDbItem, +} from "../../../src/databases/db-item"; import { createLocalTree, createRemoteTree, @@ -29,18 +37,21 @@ describe("db tree creator", () => { expect(dbTreeRoot.children.length).toBe(3); expect(dbTreeRoot.children[0]).toEqual({ kind: DbItemKind.RemoteSystemDefinedList, + selected: false, listName: "top_10", listDisplayName: "Top 10 repositories", listDescription: "Top 10 repositories of a language", }); expect(dbTreeRoot.children[1]).toEqual({ kind: DbItemKind.RemoteSystemDefinedList, + selected: false, listName: "top_100", listDisplayName: "Top 100 repositories", listDescription: "Top 100 repositories of a language", }); expect(dbTreeRoot.children[2]).toEqual({ kind: DbItemKind.RemoteSystemDefinedList, + selected: false, listName: "top_1000", listDisplayName: "Top 1000 repositories", listDescription: "Top 1000 repositories of a language", @@ -76,26 +87,30 @@ describe("db tree creator", () => { expect(dbTreeRoot).toBeTruthy(); expect(dbTreeRoot.kind).toBe(DbItemKind.RootRemote); const repositoryListNodes = dbTreeRoot.children.filter( - (child) => child.kind === DbItemKind.RemoteUserDefinedList, + isRemoteUserDefinedListDbItem, ); expect(repositoryListNodes.length).toBe(2); expect(repositoryListNodes[0]).toEqual({ kind: DbItemKind.RemoteUserDefinedList, + selected: false, listName: dbConfig.databases.remote.repositoryLists[0].name, repos: dbConfig.databases.remote.repositoryLists[0].repositories.map( (repo) => ({ kind: DbItemKind.RemoteRepo, + selected: false, repoFullName: repo, }), ), }); expect(repositoryListNodes[1]).toEqual({ kind: DbItemKind.RemoteUserDefinedList, + selected: false, listName: dbConfig.databases.remote.repositoryLists[1].name, repos: dbConfig.databases.remote.repositoryLists[1].repositories.map( (repo) => ({ kind: DbItemKind.RemoteRepo, + selected: false, repoFullName: repo, }), ), @@ -121,17 +136,17 @@ describe("db tree creator", () => { expect(dbTreeRoot).toBeTruthy(); expect(dbTreeRoot.kind).toBe(DbItemKind.RootRemote); - const ownerNodes = dbTreeRoot.children.filter( - (child) => child.kind === DbItemKind.RemoteOwner, - ); + const ownerNodes = dbTreeRoot.children.filter(isRemoteOwnerDbItem); expect(ownerNodes.length).toBe(2); expect(ownerNodes[0]).toEqual({ kind: DbItemKind.RemoteOwner, + selected: false, ownerName: dbConfig.databases.remote.owners[0], }); expect(ownerNodes[1]).toEqual({ kind: DbItemKind.RemoteOwner, + selected: false, ownerName: dbConfig.databases.remote.owners[1], }); }); @@ -155,25 +170,171 @@ describe("db tree creator", () => { expect(dbTreeRoot).toBeTruthy(); expect(dbTreeRoot.kind).toBe(DbItemKind.RootRemote); - const repoNodes = dbTreeRoot.children.filter( - (child) => child.kind === DbItemKind.RemoteRepo, - ); + const repoNodes = dbTreeRoot.children.filter(isRemoteRepoDbItem); expect(repoNodes.length).toBe(3); expect(repoNodes[0]).toEqual({ kind: DbItemKind.RemoteRepo, + selected: false, repoFullName: dbConfig.databases.remote.repositories[0], }); expect(repoNodes[1]).toEqual({ kind: DbItemKind.RemoteRepo, + selected: false, repoFullName: dbConfig.databases.remote.repositories[1], }); expect(repoNodes[2]).toEqual({ kind: DbItemKind.RemoteRepo, + selected: false, repoFullName: dbConfig.databases.remote.repositories[2], }); }); + + describe("selected db item", () => { + it("should allow selecting a remote user defined list node", () => { + const dbConfig: DbConfig = { + databases: { + remote: { + repositoryLists: [ + { + name: "my-list-1", + repositories: [ + "owner1/repo1", + "owner1/repo2", + "owner2/repo1", + ], + }, + ], + owners: [], + repositories: [], + }, + local: { + lists: [], + databases: [], + }, + }, + selected: { + kind: SelectedDbItemKind.RemoteUserDefinedList, + listName: "my-list-1", + }, + }; + + const dbTreeRoot = createRemoteTree(dbConfig); + + expect(dbTreeRoot).toBeTruthy(); + expect(dbTreeRoot.kind).toBe(DbItemKind.RootRemote); + const repositoryListNodes = dbTreeRoot.children.filter( + (child) => child.kind === DbItemKind.RemoteUserDefinedList, + ); + + expect(repositoryListNodes.length).toBe(1); + expect(repositoryListNodes[0].selected).toEqual(true); + }); + + it("should allow selecting a remote owner node", () => { + const dbConfig: DbConfig = { + databases: { + remote: { + repositoryLists: [], + owners: ["owner1", "owner2"], + repositories: [], + }, + local: { + lists: [], + databases: [], + }, + }, + selected: { + kind: SelectedDbItemKind.RemoteOwner, + ownerName: "owner1", + }, + }; + + const dbTreeRoot = createRemoteTree(dbConfig); + + expect(dbTreeRoot).toBeTruthy(); + expect(dbTreeRoot.kind).toBe(DbItemKind.RootRemote); + const ownerNodes = dbTreeRoot.children.filter( + (child) => child.kind === DbItemKind.RemoteOwner, + ); + + expect(ownerNodes.length).toBe(2); + expect(ownerNodes[0].selected).toEqual(true); + expect(ownerNodes[1].selected).toEqual(false); + }); + + it("should allow selecting a remote repo node", () => { + const dbConfig: DbConfig = { + databases: { + remote: { + repositoryLists: [], + owners: [], + repositories: ["owner1/repo1", "owner1/repo2"], + }, + local: { + lists: [], + databases: [], + }, + }, + selected: { + kind: SelectedDbItemKind.RemoteRepository, + repositoryName: "owner1/repo2", + }, + }; + + const dbTreeRoot = createRemoteTree(dbConfig); + + expect(dbTreeRoot).toBeTruthy(); + expect(dbTreeRoot.kind).toBe(DbItemKind.RootRemote); + const repoNodes = dbTreeRoot.children.filter(isRemoteRepoDbItem); + + expect(repoNodes.length).toBe(2); + expect(repoNodes[0].selected).toEqual(false); + expect(repoNodes[1].selected).toEqual(true); + }); + + it("should allow selecting a remote repo in a list", () => { + const dbConfig: DbConfig = { + databases: { + remote: { + repositoryLists: [ + { + name: "my-list-1", + repositories: ["owner1/repo1"], + }, + ], + owners: [], + repositories: ["owner1/repo2"], + }, + local: { + lists: [], + databases: [], + }, + }, + selected: { + kind: SelectedDbItemKind.RemoteRepository, + listName: "my-list-1", + repositoryName: "owner1/repo1", + }, + }; + + const dbTreeRoot = createRemoteTree(dbConfig); + + expect(dbTreeRoot).toBeTruthy(); + + const listNodes = dbTreeRoot.children.filter( + isRemoteUserDefinedListDbItem, + ); + + expect(listNodes.length).toBe(1); + expect(listNodes[0].selected).toEqual(false); + expect(listNodes[0].repos.length).toBe(1); + expect(listNodes[0].repos[0].repoFullName).toBe("owner1/repo1"); + expect(listNodes[0].repos[0].selected).toBe(true); + }); + }); }); + describe("createLocalTree", () => { it("should build root node", () => { const dbConfig: DbConfig = { @@ -252,9 +413,11 @@ describe("db tree creator", () => { expect(localListNodes.length).toBe(2); expect(localListNodes[0]).toEqual({ kind: DbItemKind.LocalList, + selected: false, listName: dbConfig.databases.local.lists[0].name, databases: dbConfig.databases.local.lists[0].databases.map((db) => ({ kind: DbItemKind.LocalDatabase, + selected: false, databaseName: db.name, dateAdded: db.dateAdded, language: db.language, @@ -263,9 +426,11 @@ describe("db tree creator", () => { }); expect(localListNodes[1]).toEqual({ kind: DbItemKind.LocalList, + selected: false, listName: dbConfig.databases.local.lists[1].name, databases: dbConfig.databases.local.lists[1].databases.map((db) => ({ kind: DbItemKind.LocalDatabase, + selected: false, databaseName: db.name, dateAdded: db.dateAdded, language: db.language, @@ -313,6 +478,7 @@ describe("db tree creator", () => { expect(localDatabaseNodes.length).toBe(2); expect(localDatabaseNodes[0]).toEqual({ kind: DbItemKind.LocalDatabase, + selected: false, databaseName: dbConfig.databases.local.databases[0].name, dateAdded: dbConfig.databases.local.databases[0].dateAdded, language: dbConfig.databases.local.databases[0].language, @@ -320,6 +486,7 @@ describe("db tree creator", () => { }); expect(localDatabaseNodes[1]).toEqual({ kind: DbItemKind.LocalDatabase, + selected: false, databaseName: dbConfig.databases.local.databases[1].name, dateAdded: dbConfig.databases.local.databases[1].dateAdded, language: dbConfig.databases.local.databases[1].language, diff --git a/extensions/ql-vscode/workspace-databases-schema.json b/extensions/ql-vscode/workspace-databases-schema.json index 027b23183..a7281b454 100644 --- a/extensions/ql-vscode/workspace-databases-schema.json +++ b/extensions/ql-vscode/workspace-databases-schema.json @@ -123,17 +123,92 @@ }, "selected": { "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["configDefined", "remoteSystemDefinedList"] + "oneOf": [ + { + "properties": { + "kind": { + "type": "string", + "enum": ["localUserDefinedList"] + }, + "listName": { + "type": "string" + } + }, + "required": ["kind", "listName"], + "additionalProperties": false }, - "value": { - "type": "string" + { + "properties": { + "kind": { + "type": "string", + "enum": ["localDatabase"] + }, + "databaseName": { + "type": "string" + }, + "listName": { + "type": "string" + } + }, + "required": ["kind", "databaseName"], + "additionalProperties": false + }, + { + "properties": { + "kind": { + "type": "string", + "enum": ["remoteSystemDefinedList"] + }, + "listName": { + "type": "string" + } + }, + "required": ["kind", "listName"], + "additionalProperties": false + }, + { + "properties": { + "kind": { + "type": "string", + "enum": ["remoteUserDefinedList"] + }, + "listName": { + "type": "string" + } + }, + "required": ["kind", "listName"], + "additionalProperties": false + }, + { + "properties": { + "kind": { + "type": "string", + "enum": ["remoteOwner"] + }, + "ownerName": { + "type": "string" + } + }, + "required": ["kind", "ownerName"], + "additionalProperties": false + }, + { + "properties": { + "kind": { + "type": "string", + "enum": ["remoteRepository"] + }, + "repositoryName": { + "type": "string" + }, + "listName": { + "type": "string" + } + }, + "required": ["kind", "repositoryName"], + "additionalProperties": false } - }, - "required": ["kind", "value"], - "additionalProperties": false + ] } }, "required": ["databases"],