diff --git a/README.md b/README.md index 9764d64..4faaed9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Once installed, just go to the command menu with: And type "Open issue". You'll see a command called "Linear: Open issue" appear. +Additionally, if your current Git branch corresponds to a related issue, a status bar button will appear, allowing you to open the issue in Linear. + This extension uses our VS Code Linear API authentication provider that is exposed by the [linear-connect](https://marketplace.visualstudio.com/items?itemName=Linear.linear-connect) extension. Feel free to use that in your own extensions! --- diff --git a/assets/icons.woff b/assets/icons.woff new file mode 100644 index 0000000..ca93de8 Binary files /dev/null and b/assets/icons.woff differ diff --git a/package.json b/package.json index b027b5b..6d39fee 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "Other" ], "activationEvents": [ - "onCommand:linear-open-issue.openIssue" + "onStartupFinished" ], "capabilities": { "virtualWorkspaces": true, @@ -42,6 +42,15 @@ "description": "Open Linear issue in the desktop app" } } + }, + "icons": { + "linear-dark-logo": { + "description": "Linear Dark Logo", + "default": { + "fontPath": "assets/icons.woff", + "fontCharacter": "\\0041" + } + } } }, "extensionDependencies": [ diff --git a/src/buttons.ts b/src/buttons.ts new file mode 100644 index 0000000..32810d2 --- /dev/null +++ b/src/buttons.ts @@ -0,0 +1,75 @@ +import * as vscode from "vscode"; +import { getCurrentBranchName, onRepoChange } from "./common/git"; +import { tryFetchCurrentIssue } from "./common/utils"; + +let openIssueButton: vscode.StatusBarItem; +let lastBranchName: string | undefined; + +export function createOpenIssueButton(): { dispose(): any } { + const disposeButton = createButton(); + const disposeSyncButton = syncButtonOnIssueChange(); + + return { + dispose: () => { + disposeButton(); + disposeSyncButton(); + }, + }; +} + +function createButton() { + openIssueButton = vscode.window.createStatusBarItem( + "linear-open-issue.openIssueButton", + vscode.StatusBarAlignment.Left + ); + openIssueButton.command = "linear-open-issue.openIssue"; + openIssueButton.name = "Open Issue"; + openIssueButton.tooltip = "Open in Linear"; + + return openIssueButton.dispose; +} + +// Observes current branch of the Git repo and fetches corresponding Linear +// issue on each change, updating the button with resulting issue data. +function syncButtonOnIssueChange() { + let repoChangeDisposer: () => any; + + let disposed = false; + + repoChangeDisposer = onRepoChange(async () => { + const branchName = getCurrentBranchName(); + // If branch didn't change, no need to call Linear for the issue. + if (branchName == lastBranchName) return; + + lastBranchName = branchName; + hideButton(); + + if (!branchName) return; + + const issue = await tryFetchCurrentIssue(); + + // Return early if the listener was disposed while fetching issue. + if (disposed) return; + + if (issue) { + showButton(issue.identifier); + } else { + hideButton(); + } + }); + + return () => { + disposed = true; + repoChangeDisposer(); + }; +} + +function showButton(issueIdentifier: string) { + openIssueButton.text = `$(linear-dark-logo) ${issueIdentifier}`; + openIssueButton.show(); +} + +function hideButton() { + openIssueButton.hide(); + openIssueButton.text = ""; +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..a01e5fe --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,29 @@ +import * as vscode from "vscode"; +import { tryFetchCurrentIssue } from "./common/utils"; + +export function registerOpenIssueCommand() { + return vscode.commands.registerCommand( + "linear-open-issue.openIssue", + async () => { + const issue = await tryFetchCurrentIssue(); + if (!issue) return; + + // Preference to open the issue in the desktop app or in the browser. + const urlPrefix = vscode.workspace + .getConfiguration() + .get("openInDesktopApp") + ? "linear://" + : "https://linear.app/"; + + // Open the URL. + vscode.env.openExternal( + vscode.Uri.parse( + urlPrefix + + issue.team.organization.urlKey + + "/issue/" + + issue.identifier + ) + ); + } + ); +} diff --git a/src/common/git.ts b/src/common/git.ts new file mode 100644 index 0000000..bd05088 --- /dev/null +++ b/src/common/git.ts @@ -0,0 +1,96 @@ +import * as vscode from "vscode"; +import { GitExtension, Repository } from "../types.d/git"; + +export function getCurrentBranchName() { + // Use VS Code's built-in Git extension API to get the current branch name. + const git = getGitExtension()?.exports?.getAPI(1); + const branchName = git?.repositories[0]?.state.HEAD?.name; + + if (!branchName) { + vscode.window.showErrorMessage( + `The current branch name could not be determined.` + ); + } + + return branchName; +} + +export function onRepoChange(listener: () => any) { + let repoOpenDisposer: (() => any) | undefined; + let repoCloseDisposer: (() => any) | undefined; + let repoChangeDisposer: (() => any) | undefined; + + let disposed = false; + + const listenRepoChangeAsync = async () => { + let activeRepo: Repository | undefined; + + // Get reference to Git extension or break execution, if it's not available + // or it wasn't activated within expected time. + const gitExtension = await initGitExtension(); + + // Return early if the listener was disposed while initializing Git extension. + if (disposed) return; + + if (!gitExtension) { + vscode.window.showErrorMessage( + `Git extension could not be found in the workspace.` + ); + return; + } + + const onRepoOpen = (repository: Repository) => { + if (activeRepo) return; + activeRepo = repository; + // Call listener on each repository change until disposed. + repoChangeDisposer = repository.state.onDidChange(listener).dispose; + listener(); + }; + + const onRepoClose = (repository: Repository) => { + if (activeRepo != repository) return; + activeRepo = undefined; + repoChangeDisposer?.(); + }; + + const gitApi = gitExtension.exports.getAPI(1); + if (gitApi.repositories.length > 0) { + onRepoOpen(gitApi.repositories[0]); + } + repoOpenDisposer = gitApi.onDidOpenRepository(onRepoOpen).dispose; + repoCloseDisposer = gitApi.onDidCloseRepository(onRepoClose).dispose; + }; + + // Listen asynchronously in order to return the disposer synchronously. + listenRepoChangeAsync(); + + return () => { + disposed = true; + repoOpenDisposer?.(); + repoCloseDisposer?.(); + repoChangeDisposer?.(); + }; +} + +async function initGitExtension() { + const gitExtension = getGitExtension(); + if (!gitExtension) return; + + // Wait for 10s for the extension activation. + const maxAttempts = 20; + const retryDelayMillis = 500; + + let attempts = 0; + while (!gitExtension.isActive && attempts != maxAttempts) { + attempts++; + await new Promise((resolve) => setTimeout(resolve, retryDelayMillis)); + } + + if (!gitExtension.isActive) return; + + return gitExtension; +} + +function getGitExtension() { + return vscode.extensions.getExtension("vscode.git"); +} diff --git a/src/common/linear.ts b/src/common/linear.ts new file mode 100644 index 0000000..350113a --- /dev/null +++ b/src/common/linear.ts @@ -0,0 +1,63 @@ +import { LinearClient } from "@linear/sdk"; +import * as vscode from "vscode"; + +export type Issue = { + identifier: string; + team: { + organization: { + urlKey: string; + }; + }; +}; + +export async function fetchIssue(branchName: string) { + const client = await createLinearClient(); + if (!client) return; + + const request: { + issueVcsBranchSearch: Issue | null; + } | null = await client.request(`query { + issueVcsBranchSearch(branchName: "${branchName}") { + identifier + team { + organization { + urlKey + } + } + } + }`); + + const issue = request?.issueVcsBranchSearch; + + if (!issue) { + vscode.window.showInformationMessage( + `No Linear issue could be found matching the branch name ${branchName} in the authenticated workspace.` + ); + } + + return issue; +} + +async function createLinearClient() { + const LINEAR_AUTHENTICATION_PROVIDER_ID = "linear"; + const LINEAR_AUTHENTICATION_SCOPES = ["read"]; + + const session = await vscode.authentication.getSession( + LINEAR_AUTHENTICATION_PROVIDER_ID, + LINEAR_AUTHENTICATION_SCOPES, + { createIfNone: true } + ); + + if (!session) { + vscode.window.showErrorMessage( + `We weren't able to log you into Linear when trying to open the issue.` + ); + return; + } + + const linearClient = new LinearClient({ + accessToken: session.accessToken, + }); + + return linearClient.client; +} diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 0000000..a3476ba --- /dev/null +++ b/src/common/utils.ts @@ -0,0 +1,17 @@ +import * as vscode from "vscode"; +import { getCurrentBranchName } from "./git"; +import { fetchIssue } from "./linear"; + +export async function tryFetchCurrentIssue() { + // Fetches Linear issue based on the current Git branch name. + const branchName = getCurrentBranchName(); + if (!branchName) return; + + try { + return await fetchIssue(branchName); + } catch (error) { + vscode.window.showErrorMessage( + `An error occurred while trying to fetch Linear issue information. Error: ${error}` + ); + } +} diff --git a/src/extension.ts b/src/extension.ts index 0e450f9..1d82306 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,93 +1,16 @@ import * as vscode from "vscode"; -import { LinearClient } from "@linear/sdk"; -import { GitExtension } from "./types.d/git"; +import { registerOpenIssueCommand } from "./commands"; +import { createOpenIssueButton } from "./buttons"; /** - * This extension registers the "Open in Linear command" upon activation. + * This extension registers: + * - the "Open in Linear" command upon activation. + * - the "Open Issue" status bar button upon workspace startup. */ -const LINEAR_AUTHENTICATION_PROVIDER_ID = "linear"; -const LINEAR_AUTHENTICATION_SCOPES = ["read"]; - export function activate(context: vscode.ExtensionContext) { - const disposable = vscode.commands.registerCommand( - "linear-open-issue.openIssue", - async () => { - const session = await vscode.authentication.getSession( - LINEAR_AUTHENTICATION_PROVIDER_ID, - LINEAR_AUTHENTICATION_SCOPES, - { createIfNone: true } - ); - - if (!session) { - vscode.window.showErrorMessage( - `We weren't able to log you into Linear when trying to open the issue.` - ); - return; - } - - const linearClient = new LinearClient({ - accessToken: session.accessToken, - }); - - // Use VS Code's built-in Git extension API to get the current branch name. - const gitExtension = - vscode.extensions.getExtension("vscode.git")?.exports; - const git = gitExtension?.getAPI(1); - const branchName = git?.repositories[0]?.state.HEAD?.name; - - try { - const request: { - issueVcsBranchSearch: { - identifier: string; - team: { - organization: { - urlKey: string; - }; - }; - } | null; - } | null = await linearClient.client.request(`query { - issueVcsBranchSearch(branchName: "${branchName}") { - identifier - team { - organization { - urlKey - } - } - } - }`); - - if (request?.issueVcsBranchSearch?.identifier) { - // Preference to open the issue in the desktop app or in the browser. - const urlPrefix = vscode.workspace - .getConfiguration() - .get("openInDesktopApp") - ? "linear://" - : "https://linear.app/"; - - // Open the URL. - vscode.env.openExternal( - vscode.Uri.parse( - urlPrefix + - request?.issueVcsBranchSearch.team.organization.urlKey + - "/issue/" + - request?.issueVcsBranchSearch.identifier - ) - ); - } else { - vscode.window.showInformationMessage( - `No Linear issue could be found matching the branch name ${branchName} in the authenticated workspace.` - ); - } - } catch (error) { - vscode.window.showErrorMessage( - `An error occurred while trying to fetch Linear issue information. Error: ${error}` - ); - } - } - ); - - context.subscriptions.push(disposable); + context.subscriptions.push(registerOpenIssueCommand()); + context.subscriptions.push(createOpenIssueButton()); } export function deactivate() {}