Skip to content

Commit 4f8ece7

Browse files
authored
Expose bundle-init wizard in empty workspace (#1034)
- Moved out bundle init logic out of BundleProjectManager into BundleInitWizard - Moved some logic to "pure" functions so they can be called without initializing a bunch of dependencies - Welcome view in the empty workspace now has Initialize Project button - Improve initial terminal prompt of the bundle-init wizard
1 parent 2533aec commit 4f8ece7

File tree

8 files changed

+354
-274
lines changed

8 files changed

+354
-274
lines changed

packages/databricks-vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@
325325
},
326326
{
327327
"view": "configurationView",
328-
"contents": "Please open a folder to start using Databricks on VSCode.\n[Open Folder](command:vscode.openFolder)",
328+
"contents": "The workspace is empty.\n[Initialize New Project](command:databricks.bundle.initNewProject)\n[Open Folder](command:vscode.openFolder)\nTo learn more about how to use Databricks with VS Code [read our docs](https://docs.databricks.com/dev-tools/vscode-ext.html).",
329329
"when": "workspaceFolderCount == 0"
330330
}
331331
],

packages/databricks-vscode/src/bundle/BundleFileSet.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Uri} from "vscode";
2-
import {BundleFileSet} from "./BundleFileSet";
2+
import {BundleFileSet, getAbsolutePath} from "./BundleFileSet";
33
import {expect} from "chai";
44
import path from "path";
55
import * as tmp from "tmp-promise";
@@ -21,14 +21,12 @@ describe(__filename, async function () {
2121
it("should return the correct absolute path", () => {
2222
const tmpdirUri = Uri.file(tmpdir.path);
2323

24-
const bundleFileSet = new BundleFileSet(tmpdirUri);
25-
26-
expect(bundleFileSet.getAbsolutePath("test.txt").fsPath).to.equal(
24+
expect(getAbsolutePath("test.txt", tmpdirUri).fsPath).to.equal(
2725
path.join(tmpdirUri.fsPath, "test.txt")
2826
);
2927

3028
expect(
31-
bundleFileSet.getAbsolutePath(Uri.file("test.txt")).fsPath
29+
getAbsolutePath(Uri.file("test.txt"), tmpdirUri).fsPath
3230
).to.equal(path.join(tmpdirUri.fsPath, "test.txt"));
3331
});
3432

packages/databricks-vscode/src/bundle/BundleFileSet.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {readFile, writeFile} from "fs/promises";
88
import {CachedValue} from "../locking/CachedValue";
99
import minimatch from "minimatch";
1010

11+
const rootFilePattern: string = "{bundle,databricks}.{yaml,yml}";
12+
const subProjectFilePattern: string = path.join("**", rootFilePattern);
13+
1114
export async function parseBundleYaml(file: Uri) {
1215
const data = yaml.parse(await readFile(file.fsPath, "utf-8"));
1316
return data as BundleSchema;
@@ -17,16 +20,41 @@ export async function writeBundleYaml(file: Uri, data: BundleSchema) {
1720
await writeFile(file.fsPath, yaml.stringify(data));
1821
}
1922

23+
export async function getSubProjects(root: Uri) {
24+
const subProjectRoots = await glob.glob(
25+
toGlobPath(getAbsolutePath(subProjectFilePattern, root).fsPath),
26+
{nocase: process.platform === "win32"}
27+
);
28+
const normalizedRoot = path.normalize(root.fsPath);
29+
return subProjectRoots
30+
.map((rootFile) => {
31+
const dirname = path.dirname(path.normalize(rootFile));
32+
const absolute = Uri.file(dirname);
33+
const relative = Uri.file(
34+
absolute.fsPath.replace(normalizedRoot, "")
35+
);
36+
return {absolute, relative};
37+
})
38+
.filter(({absolute}) => {
39+
return absolute.fsPath !== normalizedRoot;
40+
});
41+
}
42+
43+
export function getAbsolutePath(path: string | Uri, root: Uri) {
44+
if (typeof path === "string") {
45+
return Uri.joinPath(root, path);
46+
}
47+
return Uri.joinPath(root, path.fsPath);
48+
}
49+
2050
function toGlobPath(path: string) {
2151
if (process.platform === "win32") {
2252
return path.replace(/\\/g, "/");
2353
}
2454
return path;
2555
}
26-
export class BundleFileSet {
27-
private rootFilePattern: string = "{bundle,databricks}.{yaml,yml}";
28-
private subProjectFilePattern: string = `**/${this.rootFilePattern}`;
2956

57+
export class BundleFileSet {
3058
public readonly bundleDataCache: CachedValue<BundleSchema> =
3159
new CachedValue<BundleSchema>(async () => {
3260
let bundle = {};
@@ -38,16 +66,11 @@ export class BundleFileSet {
3866

3967
constructor(private readonly workspaceRoot: Uri) {}
4068

41-
getAbsolutePath(path: string | Uri, root?: Uri) {
42-
if (typeof path === "string") {
43-
return Uri.joinPath(root ?? this.workspaceRoot, path);
44-
}
45-
return Uri.joinPath(root ?? this.workspaceRoot, path.fsPath);
46-
}
47-
4869
async getRootFile() {
4970
const rootFile = await glob.glob(
50-
toGlobPath(this.getAbsolutePath(this.rootFilePattern).fsPath),
71+
toGlobPath(
72+
getAbsolutePath(rootFilePattern, this.workspaceRoot).fsPath
73+
),
5174
{nocase: process.platform === "win32"}
5275
);
5376
if (rootFile.length !== 1) {
@@ -61,7 +84,10 @@ export class BundleFileSet {
6184
): Promise<{relative: Uri; absolute: Uri}[]> {
6285
const subProjectRoots = await glob.glob(
6386
toGlobPath(
64-
this.getAbsolutePath(this.subProjectFilePattern, root).fsPath
87+
getAbsolutePath(
88+
subProjectFilePattern,
89+
root || this.workspaceRoot
90+
).fsPath
6591
),
6692
{nocase: process.platform === "win32"}
6793
);
@@ -141,7 +167,9 @@ export class BundleFileSet {
141167
isRootBundleFile(e: Uri) {
142168
return minimatch(
143169
e.fsPath,
144-
toGlobPath(this.getAbsolutePath(this.rootFilePattern).fsPath)
170+
toGlobPath(
171+
getAbsolutePath(rootFilePattern, this.workspaceRoot).fsPath
172+
)
145173
);
146174
}
147175

@@ -150,7 +178,10 @@ export class BundleFileSet {
150178
if (includedFilesGlob === undefined) {
151179
return false;
152180
}
153-
includedFilesGlob = this.getAbsolutePath(includedFilesGlob).fsPath;
181+
includedFilesGlob = getAbsolutePath(
182+
includedFilesGlob,
183+
this.workspaceRoot
184+
).fsPath;
154185
return minimatch(e.fsPath, toGlobPath(includedFilesGlob));
155186
}
156187

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import {
2+
QuickPickItem,
3+
QuickPickItemKind,
4+
Uri,
5+
window,
6+
TerminalLocation,
7+
commands,
8+
} from "vscode";
9+
import {logging} from "@databricks/databricks-sdk";
10+
import {Loggers} from "../logger";
11+
import {AuthProvider} from "../configuration/auth/AuthProvider";
12+
import {LoginWizard} from "../configuration/LoginWizard";
13+
import {CliWrapper} from "../cli/CliWrapper";
14+
import {ConfigModel} from "../configuration/models/ConfigModel";
15+
import {getSubProjects} from "./BundleFileSet";
16+
17+
export async function promptToOpenSubProjects(
18+
projects: {absolute: Uri; relative: Uri}[]
19+
) {
20+
type OpenProjectItem = QuickPickItem & {uri?: Uri};
21+
const items: OpenProjectItem[] = projects.map((project) => {
22+
return {
23+
uri: project.absolute,
24+
label: project.relative.fsPath,
25+
detail: project.absolute.fsPath,
26+
};
27+
});
28+
items.push(
29+
{label: "", kind: QuickPickItemKind.Separator},
30+
{label: "Choose another folder"}
31+
);
32+
const options = {
33+
title: "Select the project you want to open",
34+
};
35+
const item = await window.showQuickPick<OpenProjectItem>(items, options);
36+
if (!item) {
37+
return;
38+
}
39+
await commands.executeCommand("vscode.openFolder", item.uri);
40+
}
41+
42+
export class BundleInitWizard {
43+
private logger = logging.NamedLogger.getOrCreate(Loggers.Extension);
44+
45+
constructor(private cli: CliWrapper) {}
46+
47+
public async initNewProject(
48+
workspaceUri?: Uri,
49+
existingAuthProvider?: AuthProvider,
50+
configModel?: ConfigModel
51+
) {
52+
const authProvider = await this.configureAuthForBundleInit(
53+
existingAuthProvider,
54+
configModel
55+
);
56+
if (!authProvider) {
57+
this.logger.debug(
58+
"No valid auth providers, can't proceed with bundle init wizard"
59+
);
60+
return;
61+
}
62+
const parentFolder = await this.promptForParentFolder(workspaceUri);
63+
if (!parentFolder) {
64+
this.logger.debug("No parent folder provided");
65+
return;
66+
}
67+
await this.bundleInitInTerminal(parentFolder, authProvider);
68+
this.logger.debug(
69+
"Finished bundle init wizard, detecting projects to initialize or open"
70+
);
71+
const projects = await getSubProjects(parentFolder);
72+
if (projects.length > 0) {
73+
this.logger.debug(
74+
`Detected ${projects.length} sub projects after the init wizard, prompting to open one`
75+
);
76+
await promptToOpenSubProjects(projects);
77+
} else {
78+
this.logger.debug(
79+
`No projects detected after the init wizard, showing notification to open a folder manually`
80+
);
81+
const choice = await window.showInformationMessage(
82+
`We haven't detected any Databricks projects in "${parentFolder.fsPath}". If you initialized your project somewhere else, please open the folder manually.`,
83+
"Open Folder"
84+
);
85+
if (choice === "Open Folder") {
86+
await commands.executeCommand("vscode.openFolder");
87+
}
88+
}
89+
return parentFolder;
90+
}
91+
92+
private async configureAuthForBundleInit(
93+
authProvider?: AuthProvider,
94+
configModel?: ConfigModel
95+
): Promise<AuthProvider | undefined> {
96+
if (authProvider) {
97+
const response = await this.promptToUseExistingAuth(authProvider);
98+
if (response.cancelled) {
99+
return undefined;
100+
} else if (!response.approved) {
101+
authProvider = undefined;
102+
}
103+
}
104+
if (!authProvider) {
105+
authProvider = await LoginWizard.run(this.cli, configModel);
106+
}
107+
if (authProvider && (await authProvider.check())) {
108+
return authProvider;
109+
} else {
110+
return undefined;
111+
}
112+
}
113+
114+
private async promptToUseExistingAuth(authProvider: AuthProvider) {
115+
type AuthSelectionItem = QuickPickItem & {approved: boolean};
116+
const items: AuthSelectionItem[] = [
117+
{
118+
label: "Use current auth",
119+
detail: `Host: ${authProvider.host.hostname}`,
120+
approved: true,
121+
},
122+
{
123+
label: "Setup new auth",
124+
approved: false,
125+
},
126+
];
127+
const options = {
128+
title: "What auth do you want to use for the new project?",
129+
};
130+
const item = await window.showQuickPick<AuthSelectionItem>(
131+
items,
132+
options
133+
);
134+
return {
135+
cancelled: item === undefined,
136+
approved: item?.approved ?? false,
137+
};
138+
}
139+
140+
private async bundleInitInTerminal(
141+
parentFolder: Uri,
142+
authProvider: AuthProvider
143+
) {
144+
const terminal = window.createTerminal({
145+
name: "Databricks Project Init",
146+
isTransient: true,
147+
location: TerminalLocation.Editor,
148+
env: this.cli.getBundleInitEnvVars(authProvider),
149+
});
150+
const args = [
151+
"bundle",
152+
"init",
153+
"--output-dir",
154+
this.cli.escapePathArgument(parentFolder.fsPath),
155+
].join(" ");
156+
const initialPrompt = `clear; echo "Executing: databricks ${args}\nFollow the steps below to create your new Databricks project.\n"`;
157+
const finalPrompt = `echo "Press any key to close the terminal and continue ..."; read; exit`;
158+
terminal.sendText(
159+
`${initialPrompt}; ${this.cli.cliPath} ${args}; ${finalPrompt}`
160+
);
161+
return new Promise<void>((resolve) => {
162+
const closeEvent = window.onDidCloseTerminal(async (t) => {
163+
if (t !== terminal) {
164+
return;
165+
}
166+
closeEvent.dispose();
167+
resolve();
168+
});
169+
});
170+
}
171+
172+
private async promptForParentFolder(
173+
workspaceUri?: Uri
174+
): Promise<Uri | undefined> {
175+
const quickPick = window.createQuickPick();
176+
const openFolderLabel = "Open folder selection dialog";
177+
const initialValue = workspaceUri?.fsPath || process.env.HOME;
178+
if (initialValue) {
179+
quickPick.value = initialValue;
180+
}
181+
quickPick.title =
182+
"Provide a path to a folder where you would want your new project to be";
183+
quickPick.items = createParentFolderQuickPickItems(
184+
quickPick.value,
185+
openFolderLabel
186+
);
187+
quickPick.show();
188+
const disposables = [
189+
quickPick.onDidChangeValue(() => {
190+
quickPick.items = createParentFolderQuickPickItems(
191+
quickPick.value,
192+
openFolderLabel
193+
);
194+
}),
195+
];
196+
const choice = await new Promise<QuickPickItem | undefined>(
197+
(resolve) => {
198+
disposables.push(
199+
quickPick.onDidAccept(() =>
200+
resolve(quickPick.selectedItems[0])
201+
),
202+
quickPick.onDidHide(() => resolve(undefined))
203+
);
204+
}
205+
);
206+
disposables.forEach((d) => d.dispose());
207+
quickPick.hide();
208+
if (!choice) {
209+
return;
210+
}
211+
if (choice.label !== openFolderLabel) {
212+
return Uri.file(choice.label);
213+
}
214+
const choices = await window.showOpenDialog({
215+
title: "Chose a folder where you would want your new project to be",
216+
openLabel: "Select folder",
217+
defaultUri: workspaceUri,
218+
canSelectFolders: true,
219+
canSelectFiles: false,
220+
canSelectMany: false,
221+
});
222+
return choices ? choices[0] : undefined;
223+
}
224+
}
225+
226+
function createParentFolderQuickPickItems(
227+
value: string | undefined,
228+
openFolderLabel: string
229+
) {
230+
const items: QuickPickItem[] = value
231+
? [{label: value, alwaysShow: true}]
232+
: [];
233+
items.push(
234+
{label: "", kind: QuickPickItemKind.Separator, alwaysShow: true},
235+
{label: openFolderLabel, alwaysShow: true}
236+
);
237+
return items;
238+
}

0 commit comments

Comments
 (0)