Skip to content

Commit

Permalink
Merge pull request #1883 from posit-dev/dotnomad/entrypoint-tracker
Browse files Browse the repository at this point in the history
Add document tracker to set `activeFileEntrypoint` context
  • Loading branch information
dotNomad committed Jul 3, 2024
2 parents 767724f + 69d7a74 commit c2e605c
Show file tree
Hide file tree
Showing 8 changed files with 306 additions and 5 deletions.
8 changes: 7 additions & 1 deletion extensions/vscode/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,22 @@
"title": "View Content Log on Connect",
"icon": "$(output)",
"category": "Posit Publisher"
},
{
"command": "posit.publisher.deployWithEntrypoint",
"title": "Deploy with this Entrypoint",
"icon": "$(cloud-upload)",
"category": "Posit Publisher"
}
],
"menus": {
"editor/title": [
{
"command": "posit.publisher.deployWithEntrypoint",
"group": "navigation",
"when": "posit.publish.activeFileEntrypoint == true"
}
],
"view/title": [
{
"command": "posit.publisher.configurations.add",
Expand Down Expand Up @@ -685,6 +698,7 @@
"eventsource": "^2.0.2",
"get-port": "5.1.1",
"mutexify": "^1.4.0",
"retry": "^0.13.1"
"retry": "^0.13.1",
"vscode-uri": "^3.0.8"
}
}
2 changes: 1 addition & 1 deletion extensions/vscode/src/api/resources/Configurations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class Configurations {
// 200 - success
// 400 - bad request
// 500 - internal server error
inspect(python?: string, params?: { dir?: string }) {
inspect(python?: string, params?: { dir?: string; entrypoint?: string }) {
return this.client.post<ConfigurationInspectionResult[]>(
"/inspect",
{
Expand Down
8 changes: 8 additions & 0 deletions extensions/vscode/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ const baseCommands = {
InitProject: "posit.publisher.init-project",
ShowOutputChannel: "posit.publisher.showOutputChannel",
ShowPublishingLog: "posit.publisher.showPublishingLog",
DeployWithEntrypoint: "posit.publisher.deployWithEntrypoint",
} as const;

const baseContexts = {
ActiveFileEntrypoint: "posit.publish.activeFileEntrypoint",
} as const;

const logsCommands = {
Expand Down Expand Up @@ -91,6 +96,8 @@ const homeViewCommands = {
NavigateToDeploymentContent:
"posit.publisher.homeView.navigateToDeployment.Content",
ShowContentLogs: "posit.publisher.homeView.navigateToDeployment.ContentLog",
// Added automatically by VSCode with view registration
Focus: "posit.publisher.homeView.focus",
} as const;

const homeViewContexts = {
Expand Down Expand Up @@ -120,6 +127,7 @@ export const Commands = {
};

export const Contexts = {
...baseContexts,
Configurations: configurationsContexts,
ContentRecords: contentRecordsContexts,
Credentials: credentialsContexts,
Expand Down
214 changes: 214 additions & 0 deletions extensions/vscode/src/entrypointTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright (C) 2023 by Posit Software, PBC.

import {
Disposable,
TextDocument,
TextEditor,
commands,
window,
workspace,
} from "vscode";
import { Utils as uriUtils } from "vscode-uri";

import { useApi } from "src/api";
import { Contexts } from "src/constants";
import { getPythonInterpreterPath } from "src/utils/config";
import { isActiveDocument, relativeDir } from "src/utils/files";
import { hasKnownContentType } from "src/utils/inspect";
import { getSummaryStringFromError } from "src/utils/errors";

/**
* Determines if a text document is an entrypoint file.
*
* @param document The text document to inspect
* @returns If the text document is an entrypoint
*/
async function isDocumentEntrypoint(document: TextDocument): Promise<boolean> {
const dir = relativeDir(document.uri);
// If the file is outside the workspace, it cannot be an entrypoint
if (dir === undefined) {
return false;
}

try {
const api = await useApi();
const python = await getPythonInterpreterPath();

const response = await api.configurations.inspect(python, {
dir: dir,
entrypoint: uriUtils.basename(document.uri),
});

return hasKnownContentType(response.data);
} catch (error: unknown) {
const summary = getSummaryStringFromError(
"entrypointTracker::isDocumentEntrypoint",
error,
);
window.showInformationMessage(summary);
return false;
}
}

/**
* Tracks whether a document is an entrypoint file and sets extension context.
*/
export class TrackedEntrypointDocument {
readonly document: TextDocument;
private isEntrypoint: boolean;

private requiresUpdate: boolean = false;

private constructor(document: TextDocument, isEntrypoint: boolean) {
this.document = document;
this.isEntrypoint = isEntrypoint;
}

static async create(document: TextDocument) {
const isEntrypoint = await isDocumentEntrypoint(document);
return new TrackedEntrypointDocument(document, isEntrypoint);
}

/**
* Sets the file entrypoint context with this as the active file.
* @param options Options for the activation
* @param options.forceUpdate Whether to force the entrypoint to update
*/
async activate(options?: { forceUpdate?: boolean }) {
// change based on if entrypoint
if (options?.forceUpdate) {
await this.update();
} else if (this.requiresUpdate) {
await this.update();
}

commands.executeCommand(
"setContext",
Contexts.ActiveFileEntrypoint,
this.isEntrypoint,
);
}

/**
* Updates the entrypoint next time the document is activated.
*/
updateNextActivate() {
this.requiresUpdate = true;
}

/**
* Updates whether or not the document is an entrypoint file.
*/
private async update() {
this.requiresUpdate = false;
this.isEntrypoint = await isDocumentEntrypoint(this.document);
}
}

/**
* Tracks active documents and assists in determining extension context.
*/
export class DocumentTracker implements Disposable {
private disposable: Disposable;

private readonly documents = new Map<
TextDocument,
TrackedEntrypointDocument
>();

constructor() {
this.disposable = Disposable.from(
window.onDidChangeActiveTextEditor(this.onActiveTextEditorChanged, this),
workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this),
workspace.onDidSaveTextDocument(this.onTextDocumentSaved, this),
);

// activate the initial active file
this.onActiveTextEditorChanged(window.activeTextEditor);
}

dispose() {
this.disposable.dispose();
this.documents.clear();
}

/**
* Starts tracking a document
* @param document The document to track
* @returns The TrackedEntrypointDocument created
*/
async addDocument(document: TextDocument) {
const entrypoint = await TrackedEntrypointDocument.create(document);
this.documents.set(document, entrypoint);
return entrypoint;
}

/**
* Stops tracking a document
* @param document The document to stop tracking
*/
removeDocument(document: TextDocument) {
this.documents.delete(document);
}

/**
* Listener function for changes to the active text editor.
*
* Adds new documents to the tracker, and activates the associated
* TrackedEntrypointDocument
* @param editor The active text editor
*/
async onActiveTextEditorChanged(editor: TextEditor | undefined) {
if (editor === undefined) {
commands.executeCommand(
"setContext",
Contexts.ActiveFileEntrypoint,
undefined,
);
return;
}

let tracked = this.documents.get(editor.document);

if (tracked === undefined) {
tracked = await this.addDocument(editor.document);
}

tracked.activate();
}

/**
* Listener function for the closing of a text document.
* Stops the document from being tracked.
*
* @param document The closed document
*/
onTextDocumentClosed(document: TextDocument) {
this.removeDocument(document);
}

/**
* Listener function for the saving of a text document.
* Triggers the document to update next time it is activated.
*
* @param document The saved document
*/
async onTextDocumentSaved(document: TextDocument) {
const tracked = this.documents.get(document);

if (tracked) {
if (isActiveDocument(document)) {
tracked.activate({ forceUpdate: true });
} else {
tracked.updateNextActivate();
}
return;
}

// Track the untracked document
const newTracked = await this.addDocument(document);
if (isActiveDocument(document)) {
newTracked.activate();
}
}
}
11 changes: 10 additions & 1 deletion extensions/vscode/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (C) 2024 by Posit Software, PBC.

import { ExtensionContext, commands } from "vscode";
import { ExtensionContext, Uri, commands } from "vscode";

import * as ports from "src/ports";
import { Service } from "src/services";
Expand All @@ -14,6 +14,7 @@ import { EventStream } from "src/events";
import { HomeViewProvider } from "src/views/homeView";
import { WatcherManager } from "src/watchers";
import { Commands } from "src/constants";
import { DocumentTracker } from "./entrypointTracker";

const STATE_CONTEXT = "posit.publish.state";

Expand Down Expand Up @@ -100,6 +101,14 @@ export async function activate(context: ExtensionContext) {
),
);
setStateContext(PositPublishState.initialized);

context.subscriptions.push(
new DocumentTracker(),
commands.registerCommand(Commands.DeployWithEntrypoint, (uri: Uri) => {
commands.executeCommand(Commands.HomeView.Focus);
console.log("'Deploy with this Entrypoint' button hit!", uri);
}),
);
}

// This method is called when your extension is deactivated
Expand Down
35 changes: 34 additions & 1 deletion extensions/vscode/src/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
workspace,
window,
commands,
TextDocument,
} from "vscode";
import { Utils as uriUtils } from "vscode-uri";

import { ContentRecordFile } from "../api";
import { ContentRecordFile } from "src/api";

export async function fileExists(fileUri: Uri): Promise<boolean> {
try {
Expand Down Expand Up @@ -234,3 +236,34 @@ export function splitFilesOnInclusion(
export function isRelativePathRoot(path: string): boolean {
return path === ".";
}

export function isActiveDocument(document: TextDocument): boolean {
const editor = window.activeTextEditor;
return editor?.document === document;
}

/**
* Returns a VSCode workspace relative directory path for a given URI.
*
* @param uri The URI to get the relative dirname of
* @returns A relative path `string` if the URI is in the workspace
* @returns `undefined` if the URI is not in the workspace
*/
export function relativeDir(uri: Uri): string | undefined {
const workspaceFolder = workspace.getWorkspaceFolder(uri);

if (!workspaceFolder) {
// File is outside of the workspace
return undefined;
}

const dirname = uriUtils.dirname(uri);

// If the file is in the workspace root, return "."
if (dirname.fsPath === workspaceFolder.uri.fsPath) {
return ".";
}

// Otherwise, return the relative path VSCode expects
return workspace.asRelativePath(dirname);
}
Loading

0 comments on commit c2e605c

Please sign in to comment.