Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Clone from Diffinery in JetBrains IDEs (plugin served from diffinery.com) (#249)

* JetBrains plugin: Diffinery in Get from VCS, passkey sign-in, Share Project
* Serve the JetBrains plugin and its plugin repository from diffinery.com
* JetBrains plugin: guide, Marketplace-style install, verifier, tests
test-bot authored 1 parent bc37204commit be7e0c47ca9483564a57057732d13a9d9d6beab5Browse files
53 files changed+3192−33
Files (53)
addedapps/diffinery/docs/images/jetbrains-consent.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/jetbrains-get-from-vcs.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/jetbrains-plugins.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/jetbrains-repositories.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/jetbrains-share.webp+0−0View file
Binary file not shown.
modifiedapps/diffinery/docs/migrate-from-github.md+1−1View file
@@ -806,7 +806,7 @@
806806
807807 Git asks for a password the first time: use a Diffinery token, or run `diffinery auth setup-git` once so Git gets it from the CLI. See [Git over HTTPS](/docs/repositories/git).
808808
809-People who clone from VS Code, Cursor or Windsurf install the Diffinery extension, which puts **Clone from Diffinery** next to **Clone from GitHub** in the clone picker and signs Git in through the browser: see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
809+People who clone from VS Code, Cursor or Windsurf install the Diffinery extension, which puts **Clone from Diffinery** next to **Clone from GitHub** in the clone picker and signs Git in through the browser: see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor). People who use IntelliJ IDEA, PyCharm, WebStorm, GoLand or another JetBrains IDE install the Diffinery plugin, which adds **Diffinery** next to GitHub in **Get from Version Control**: see [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
810810
811811 Also update anything else that holds the old URL: CI and deploy scripts, package manifests (`repository` fields), submodule URLs in `.gitmodules`, badges and links in READMEs.
812812
modifiedapps/diffinery/docs/nav.json+1−0View file
@@ -41,6 +41,7 @@
4141 "repositories/overview",
4242 "repositories/git",
4343 "repositories/clone-in-your-editor",
44+ "repositories/clone-in-jetbrains-ides",
4445 "repositories/git-lfs",
4546 "repositories/browsing-code",
4647 "repositories/branch-protection",
modifiedapps/diffinery/docs/quickstart.md+1−1View file
@@ -51,7 +51,7 @@
5151
5252 `diffinery login` opens an **Authorize the Diffinery CLI** page showing your computer's name. Check it, keep **Write** access for this guide, and click **Authorize**. The terminal prints `✓ Logged in to https://diffinery.com as diffy`. No token to copy: the CLI gets its own, valid for 90 days and listed under **Settings → Access tokens**, and `diffinery logout` revokes it. On a machine without a browser (over SSH, for example), `diffinery login --device` prints a code to enter at `https://diffinery.com/login/device` from any device.
5353
54-Working in VS Code, Cursor or Windsurf? Install the editor extension with `curl -fsSL https://diffinery.com/install-editor | bash`, then use **Git: Clone → Clone from Diffinery**. See [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
54+Working in VS Code, Cursor or Windsurf? Install the editor extension with `curl -fsSL https://diffinery.com/install-editor | bash`, then use **Git: Clone → Clone from Diffinery**. See [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor). In IntelliJ IDEA, PyCharm or another JetBrains IDE, install the Diffinery plugin and use **Get from Version Control → Diffinery**: see [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
5555
5656 ## 3. Tokens for scripts and CI
5757
addedapps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+101−0View file
@@ -1,0 +1,101 @@
1+---
2+title: Clone in JetBrains IDEs
3+description: Add Diffinery to Get from Version Control in IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs, sign in with your passkey, and clone, push and share without tokens.
4+---
5+
6+When you clone a repository in IntelliJ IDEA, PyCharm, WebStorm, GoLand or another JetBrains IDE, **Get from Version Control** lists GitHub and GitLab. The Diffinery plugin adds **Diffinery** to that list, shows your repositories, and signs Git in for you, so clone, pull and push work without a token or password. It works in every JetBrains IDE from version 2024.3 on, including PhpStorm, RubyMine, CLion, Rider, RustRover and Android Studio.
7+
8+![Get from Version Control in IntelliJ IDEA with Diffinery listed under GitHub, GitHub Enterprise and GitLab, showing "Sign in to Diffinery" with a Sign In with Browser button.](/docs/images/jetbrains-get-from-vcs.webp)
9+
10+## 1. Install the plugin
11+
12+The plugin comes from diffinery.com rather than the JetBrains Marketplace. Add diffinery.com as a plugin repository once, and the IDE installs the plugin and its updates from there like any other:
13+
14+1. Open **Settings** (`Ctrl+Alt+S`, or **IntelliJ IDEA → Settings…** on a Mac) and choose **Plugins**.
15+2. Choose the gear icon (⚙) next to the **Installed** tab, then **Manage Plugin Repositories…**.
16+3. Choose **+**, paste this address, and choose **OK**:
17+
18+ ```text
19+ https://diffinery.com/-/jetbrains/updatePlugins.xml
20+ ```
21+
22+4. On the **Marketplace** tab, search for **Diffinery** and choose **Install**.
23+5. The IDE says that Diffinery isn't a JetBrains plugin. Choose **Accept**, then **OK** to close Settings. The plugin starts right away; no restart is needed.
24+
25+![The Plugins page of IntelliJ IDEA's Settings, with Diffinery found on the Marketplace tab and its Install button.](/docs/images/jetbrains-plugins.webp)
26+
27+To install from a terminal instead, close the IDE and run its command-line launcher with the plugin's ID and the repository address, for example for IntelliJ IDEA:
28+
29+```sh
30+idea installPlugins com.diffinery https://diffinery.com/-/jetbrains/updatePlugins.xml
31+```
32+
33+Use `pycharm`, `webstorm`, `goland` and so on for the other IDEs, or the script in the IDE's `bin` folder, such as `bin/idea.sh` on Linux. The IDE asks you to accept the plugin the next time it starts.
34+
35+Without the plugin repository: download [diffinery-jetbrains.zip](https://diffinery.com/-/jetbrains/diffinery-jetbrains.zip), then choose the gear icon (⚙) on the **Plugins** page → **Install Plugin from Disk…** and pick the file. A plugin installed this way doesn't update itself.
36+
37+## 2. Clone a repository
38+
39+1. Choose **File → New → Project from Version Control…**, or **Clone Repository** on the welcome screen.
40+2. Choose **Diffinery** in the list on the left.
41+3. The first time, choose **Sign In with Browser…**. Your browser opens Diffinery's consent page.
42+4. On the consent page, check that it says **Diffinery plugin for JetBrains IDEs**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the IDE.
43+5. Pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link.
44+6. Check the **Directory** and choose **Clone**. The IDE clones the repository and opens it.
45+
46+![Diffinery's consent page for the JetBrains plugin: "Connect Diffinery plugin for JetBrains IDEs to Diffinery", an app published by diffinery.com that sends you back to an app on this computer, with Act as set to you and Write access selected.](/docs/images/jetbrains-consent.webp)
47+
48+![The Diffinery page of Get from Version Control, signed in to diffinery.com as ada, with a search for "hello" finding the private repository ada/hello-editor and the directory it clones into.](/docs/images/jetbrains-repositories.webp)
49+
50+**Git → Clone from Diffinery…** goes straight to the Diffinery page.
51+
52+## Check that it works
53+
54+- **Settings → Version Control → Diffinery** shows your login.
55+- In the cloned project, **Git → Pull…** and **Git → Push…** work without asking for a password.
56+- On diffinery.com, **Settings → Access tokens → Connected apps** lists **Diffinery plugin for JetBrains IDEs**.
57+
58+## Open in IntelliJ IDEA from diffinery.com
59+
60+Every repository's **Clone** box on diffinery.com has an **IntelliJ IDEA** button. It opens the IDE through JetBrains Toolbox, which asks where to clone. For a private repository, the plugin signs Git in when Git asks; without it, Git asks for your username and an [access token](/docs/accounts/tokens). The link follows this pattern:
61+
62+```text
63+jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git
64+```
65+
66+## Share a local project on Diffinery
67+
68+In a project that's a Git repository with at least one commit, choose **Git → Share Project on Diffinery…**. Choose the owner (you or one of your organizations), the name, and whether it's private. The plugin creates the repository, adds it as the `origin` remote (or `diffinery`, if `origin` is taken), and pushes the current branch. A notification links to the new repository.
69+
70+![The Share Project on Diffinery dialog: owner ada, repository name difference-engine, and Private ticked.](/docs/images/jetbrains-share.webp)
71+
72+## How sign-in and Git passwords work
73+
74+- Signing in uses Diffinery's [OAuth sign-in](/docs/connect/how-sign-in-works), the same one Claude and Codex use. The IDE never sees your passkey. It gets a token that lasts 8 hours and renews itself while the IDE runs; after 30 days unused the connection lapses and the IDE asks you to sign in again.
75+- The token is kept in the IDE's password storage (your system keychain, or the IDE's own encrypted file, as set under **Settings → Appearance & Behavior → System Settings → Passwords**).
76+- When Git, run by the IDE, needs a password for diffinery.com, the plugin answers with your signed-in account. Git in a terminal outside the IDE doesn't ask the plugin: let the [`diffinery` CLI](/docs/repositories/git#let-the-cli-handle-credentials) answer there (`diffinery login`, then `diffinery auth setup-git`).
77+- **Sign out** on the Diffinery page of Get from Version Control, or in **Settings → Version Control → Diffinery**, disconnects the IDE on Diffinery too. **Disconnect** under **Settings → Access tokens → Connected apps** on diffinery.com does the same from the website.
78+- **Use an access token…** signs in with a token you paste instead, for an [agent](/docs/agents/overview) account or a computer without a browser. Create one under **Settings → Access tokens** with Write access. A pasted token isn't renewed, and isn't revoked when you sign out.
79+
80+## Settings
81+
82+**Settings → Version Control → Diffinery** shows the account you're signed in with, with **Sign In with Browser…**, **Use an access token…** and **Sign out**, and the **Server**: `https://diffinery.com` unless you use a self-hosted Diffinery. Changing the server signs you out. For a self-hosted Diffinery, the plugin repository is the same address on your server, such as `https://git.example.com/-/jetbrains/updatePlugins.xml`.
83+
84+## Troubleshooting
85+
86+| What you see | What to do |
87+| --- | --- |
88+| No **Diffinery** in Get from Version Control | Check the plugin is installed and enabled under **Settings → Plugins → Installed**. It needs the IDE's bundled Git plugin, which is on by default. |
89+| Searching the Marketplace tab doesn't find Diffinery | Check the plugin repository address under ⚙ → **Manage Plugin Repositories…**: `https://diffinery.com/-/jetbrains/updatePlugins.xml`. Behind a proxy, set it under **Settings → Appearance & Behavior → System Settings → HTTP Proxy**. |
90+| The browser opens but the IDE doesn't continue after **Connect** | The browser hands the answer to the IDE on a one-time address on your computer (`127.0.0.1`). Make sure the IDE's **Sign In to Diffinery** progress is still showing, then try again. A firewall or proxy that blocks local addresses stops it; use **Use an access token…** instead. |
91+| "This app isn't registered with Diffinery" on the consent page | The **Server** in **Settings → Version Control → Diffinery** points at a server without the JetBrains sign-in. Use **Use an access token…**, or update the server. |
92+| Git still asks for a password | Check that the remote is on the server you're signed in to (**Git → Manage Remotes…**). A password saved earlier for diffinery.com in the IDE's password storage or Git's credential helper can win; remove it. |
93+| "Your Diffinery sign-in has expired or was disconnected" | The connection was disconnected on diffinery.com or went unused for 30 days. Choose **Sign In…** in the notification. |
94+| An older IDE than 2024.3 | Update the IDE, or clone with the clone URL and an [access token](/docs/accounts/tokens) under **Repository URL** in Get from Version Control. |
95+
96+## For AI agents reading this page
97+
98+- To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](/docs/agents/mcp)'s tools. The plugin is for people using a JetBrains IDE.
99+- When a person asks how to open a Diffinery repository in IntelliJ IDEA, PyCharm or another JetBrains IDE, point them to this page: they add `https://diffinery.com/-/jetbrains/updatePlugins.xml` under **Settings → Plugins → ⚙ → Manage Plugin Repositories…**, install **Diffinery**, then use **Get from Version Control → Diffinery**. Don't try to complete the browser sign-in for them.
100+- The plugin repository at `https://diffinery.com/-/jetbrains/updatePlugins.xml` names the current version and its download; the plugin's ID is `com.diffinery`.
101+- For VS Code, Cursor and Windsurf, see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
modifiedapps/diffinery/docs/repositories/clone-in-your-editor.md+3−9View file
@@ -1,6 +1,6 @@
11 ---
22 title: Clone in VS Code, Cursor and other editors
3-description: Add "Clone from Diffinery" to VS Code, Cursor, Windsurf and VSCodium, sign in with your passkey, and clone, push and publish without tokens. Also JetBrains IDEs.
3+description: Add "Clone from Diffinery" to VS Code, Cursor, Windsurf and VSCodium, sign in with your passkey, and clone, push and publish without tokens.
44 ---
55
66 When you clone a repository in VS Code or Cursor, the clone picker offers **Clone from GitHub**. The Diffinery extension adds **Clone from Diffinery** next to it, lists your repositories, and signs Git in for you, so clone, pull and push work without a token or password. It works in every editor built on VS Code: VS Code, Cursor, Windsurf, VSCodium and others.
@@ -57,7 +57,7 @@
5757
5858 ## Open in your editor from diffinery.com
5959
60-Every repository's **Clone** box on diffinery.com has **Open in VS Code**, **Cursor**, **Windsurf** and **IntelliJ IDEA** buttons. They use the editors' own clone links, so they work even without the extension: your browser asks to open the editor, and the editor asks where to clone. For a private repository, the extension signs Git in when Git asks; without it, Git asks for your username and an [access token](/docs/accounts/tokens).
60+Every repository's **Clone** box on diffinery.com has **Open in VS Code**, **Cursor**, **Windsurf** and **IntelliJ IDEA** buttons. They use the editors' own clone links, so they work even without the extension: your browser asks to open the editor, and the editor asks where to clone. For a private repository, the extension (or, in IntelliJ IDEA, the [JetBrains plugin](/docs/repositories/clone-in-jetbrains-ides)) signs Git in when Git asks; without it, Git asks for your username and an [access token](/docs/accounts/tokens).
6161
6262 The links follow this pattern, if you want to put them in a README or a script:
6363
@@ -89,14 +89,8 @@
8989
9090 ## JetBrains IDEs
9191
92-IntelliJ IDEA, WebStorm, PyCharm, GoLand and the other JetBrains IDEs clone Diffinery repositories with their built-in Git support:
92+IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs have their own Diffinery plugin, which adds **Diffinery** to **Get from Version Control** with the same passkey sign-in: see [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
9393
94-1. Choose **File → New → Project from Version Control** (or **Clone Repository** on the welcome screen).
95-2. Keep **Repository URL**, set **Version control** to **Git**, and paste the clone URL, such as `https://diffinery.com/acme/website.git`.
96-3. When the IDE asks for credentials, enter your Diffinery username and an [access token](/docs/accounts/tokens) as the password, and tick **Remember**.
97-
98-The **IntelliJ IDEA** button on a repository's page does the first two steps for you through JetBrains Toolbox.
99-
10094 ## Other editors and Git tools
10195
10296 Any Git client clones Diffinery repositories over HTTPS with your username and an access token: see [Git](/docs/repositories/git). For Git in any terminal, even with the editor closed, let the [`diffinery` CLI](/docs/repositories/git#let-the-cli-handle-credentials) answer Git's password prompts: `diffinery login`, then `diffinery auth setup-git`. Git then asks the CLI first, and the editor's sign-in is used only inside the editor.
modifiedapps/diffinery/docs/repositories/git.md+1−1View file
@@ -48,7 +48,7 @@
4848
4949 ### Let your editor handle credentials
5050
51-In VS Code, Cursor, Windsurf or VSCodium, the [Diffinery extension](/docs/repositories/clone-in-your-editor) adds **Clone from Diffinery** to the clone picker and answers Git's password prompts with the account you signed in with in the browser. No token to create or paste.
51+In VS Code, Cursor, Windsurf or VSCodium, the [Diffinery extension](/docs/repositories/clone-in-your-editor) adds **Clone from Diffinery** to the clone picker and answers Git's password prompts with the account you signed in with in the browser. No token to create or paste. In IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs, the [Diffinery plugin](/docs/repositories/clone-in-jetbrains-ides) does the same from **Get from Version Control**.
5252
5353 ### Let the CLI handle credentials
5454
modifiedapps/diffinery/scripts/build-docs.mjs+16−2View file
@@ -6,6 +6,8 @@
66 // - the editor extension for VS Code and Cursor (apps/vscode), packed as a .vsix, served at
77 // /-/vscode/diffinery.vsix, and its install scripts (apps/vscode/install), served at /install-editor
88 // and /install-editor.ps1
9+// - the plugin for JetBrains IDEs (apps/jetbrains), built and checked in at apps/jetbrains/dist (the build here has no
10+// JDK; scripts/plugin.mjs there refuses a zip built from older sources), served at /-/jetbrains/
911 // The REST API and MCP tool references are generated at runtime from the live
1012 // router and tool list (src/docs/reference.ts), so none of the references can drift.
1113 //
@@ -25,6 +27,7 @@
2527 const cliInstall = path.resolve(app, "../cli/install");
2628 const editorPackager = path.resolve(app, "../vscode/scripts/package.mjs");
2729 const editorInstall = path.resolve(app, "../vscode/install");
30+const jetbrainsReader = path.resolve(app, "../jetbrains/scripts/plugin.mjs");
2831 const out = path.join(app, "src/docs/content.generated.ts");
2932
3033 /** Pages produced in code rather than from a Markdown file. */
@@ -232,6 +235,12 @@
232235 await rm(tmp, { recursive: true, force: true });
233236 }
234237 const editor = await (await import(pathToFileURL(editorPackager).href)).packageExtension();
238+let jetbrains;
239+try {
240+ jetbrains = await (await import(pathToFileURL(jetbrainsReader).href)).readPlugin();
241+} catch (e) {
242+ problems.push(e.message);
243+}
235244 pages["reference/cli"] = {
236245 title: "CLI reference",
237246 description: "Every command and option of the diffinery command-line client, generated from its source.",
@@ -244,7 +253,7 @@
244253 }
245254
246255 const ordered = Object.fromEntries(navSlugs.filter((s) => pages[s]).map((s) => [s, pages[s]]));
247-const src = `// Generated by scripts/build-docs.mjs from docs/, apps/cli and apps/vscode. Do not edit.
256+const src = `// Generated by scripts/build-docs.mjs from docs/, apps/cli, apps/vscode and apps/jetbrains/dist. Do not edit.
248257 /* eslint-disable */
249258 export interface DocPage { title: string; description: string; markdown: string }
250259 export interface DocSection { title: string; pages: string[] }
@@ -259,9 +268,14 @@
259268 export const EDITOR_EXTENSION_VSIX_B64 = ${JSON.stringify(editor.vsix.toString("base64"))};
260269 export const EDITOR_INSTALL_SH = ${JSON.stringify(await readFile(path.join(editorInstall, "install.sh"), "utf8"))};
261270 export const EDITOR_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(editorInstall, "install.ps1"), "utf8"))};
271+export const JETBRAINS_PLUGIN_VERSION = ${JSON.stringify(jetbrains.version)};
272+export const JETBRAINS_PLUGIN_SINCE_BUILD = ${JSON.stringify(jetbrains.sinceBuild)};
273+export const JETBRAINS_PLUGIN_DESCRIPTION = ${JSON.stringify(jetbrains.description)};
274+export const JETBRAINS_PLUGIN_SHA256 = ${JSON.stringify(createHash("sha256").update(jetbrains.zip).digest("hex"))};
275+export const JETBRAINS_PLUGIN_ZIP_B64 = ${JSON.stringify(jetbrains.zip.toString("base64"))};
262276 export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))};
263277 export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))};
264278 `;
265279 await mkdir(path.dirname(out), { recursive: true });
266280 await writeFile(out, src);
267-console.log(`docs: ${Object.keys(ordered).length} pages (+${RUNTIME_PAGES.size} generated at runtime), ${Object.keys(images).length} images, CLI ${cli.VERSION} (${Math.round(cli.bin.length / 1024)} KiB), editor extension ${editor.version} (${Math.round(editor.vsix.length / 1024)} KiB)`);
281+console.log(`docs: ${Object.keys(ordered).length} pages (+${RUNTIME_PAGES.size} generated at runtime), ${Object.keys(images).length} images, CLI ${cli.VERSION} (${Math.round(cli.bin.length / 1024)} KiB), editor extension ${editor.version} (${Math.round(editor.vsix.length / 1024)} KiB), JetBrains plugin ${jetbrains.version} (${Math.round(jetbrains.zip.length / 1024)} KiB)`);
modifiedapps/diffinery/scripts/docs-screenshots.mjs+5−3View file
@@ -4,9 +4,9 @@
44 //
55 // pnpm --filter @diffinery/app build && node scripts/docs-screenshots.mjs
66 //
7-// Writes docs/images/consent-claude-code.webp, consent-codex.webp, editor-consent.webp and connected-apps.webp; name
8-// some of them as arguments to write only those. The editor extension's own pictures (the clone picker) come from
9-// test/e2e/editor-smoke.mjs.
7+// Writes docs/images/consent-claude-code.webp, consent-codex.webp, editor-consent.webp, jetbrains-consent.webp and
8+// connected-apps.webp; name some of them as arguments to write only those. The editor extension's and the JetBrains
9+// plugin's own pictures come from test/e2e/editor-smoke.mjs and test/e2e/jetbrains-smoke.mjs.
1010 import { spawn, execFileSync } from "node:child_process";
1111 import { createHash, randomBytes } from "node:crypto";
1212 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -136,6 +136,8 @@
136136 await connect("https://chatgpt.com/oauth/codex/client.json", "http://127.0.0.1:1455/callback", "consent-codex.webp");
137137 // The editor extension, as it signs in from VS Code or Cursor: as the person, for Git, without the MCP resource.
138138 await connect(`${PUBLIC}/oauth/clients/editor.json`, "http://127.0.0.1:52817/callback", "editor-consent.webp", { actAs: null, scope: "write", resource: null });
139+ // The JetBrains plugin, the same way from IntelliJ IDEA, PyCharm or another JetBrains IDE.
140+ await connect(`${PUBLIC}/oauth/clients/jetbrains.json`, "http://127.0.0.1:52817/callback", "jetbrains-consent.webp", { actAs: null, scope: "write", resource: null });
139141
140142 await page.goto(`${ORIGIN}/settings/tokens`);
141143 await page.waitForLoadState("networkidle");
modifiedapps/diffinery/src/auth/oauth.ts+24−11View file
@@ -48,21 +48,34 @@
4848 };
4949
5050 /**
51- * Diffinery's own extension for VS Code and the editors built on it (apps/vscode). Its client_id is the URL of
52- * a metadata document this server publishes about itself, so it's resolved here instead of fetched (a client_id
53- * can't point at Diffinery itself). Each editor sends people back to the extension through its own URL scheme;
54- * an editor not listed uses a loopback address on the person's computer.
51+ * Diffinery's own editor integrations: the extension for VS Code and the editors built on it (apps/vscode) and the
52+ * plugin for JetBrains IDEs (apps/jetbrains). Each client_id is the URL of a metadata document this server publishes
53+ * about itself, so it's resolved here instead of fetched (a client_id can't point at Diffinery itself). VS Code-based
54+ * editors may send people back through their own URL scheme; both sign in through a loopback address on the person's
55+ * computer otherwise.
5556 */
5657 export const EDITOR_CLIENT_PATH = "/oauth/clients/editor.json";
58+export const JETBRAINS_CLIENT_PATH = "/oauth/clients/jetbrains.json";
5759 export const EDITOR_EXTENSION_ID = "codician.diffinery";
5860 export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"] as const;
59-export const editorClientId = (env: Pick<Env, "PUBLIC_URL">) => `${base(env)}${EDITOR_CLIENT_PATH}`;
60-export function editorClientDocument(env: Pick<Env, "PUBLIC_URL">) {
61+const OWN_CLIENTS: Record<string, { name: string; docs: string; redirectUris: string[] }> = {
62+ [EDITOR_CLIENT_PATH]: {
63+ name: "Diffinery extension for VS Code and Cursor",
64+ docs: "/docs/repositories/clone-in-your-editor",
65+ redirectUris: [...EDITOR_SCHEMES.map((s) => `${s}://${EDITOR_EXTENSION_ID}/oauth`), "http://127.0.0.1/callback"],
66+ },
67+ [JETBRAINS_CLIENT_PATH]: { name: "Diffinery plugin for JetBrains IDEs", docs: "/docs/repositories/clone-in-jetbrains-ides", redirectUris: ["http://127.0.0.1/callback"] },
68+};
69+export const OWN_CLIENT_PATHS = Object.keys(OWN_CLIENTS);
70+/** The metadata document of one of Diffinery's own clients, by its path on this server, or null. */
71+export function ownClientDocument(env: Pick<Env, "PUBLIC_URL">, path: string) {
72+ const c = OWN_CLIENTS[path];
73+ if (!c) return null;
6174 return {
62- client_id: editorClientId(env),
63- client_name: "Diffinery extension for VS Code and Cursor",
64- client_uri: `${base(env)}/docs/repositories/clone-in-your-editor`,
65- redirect_uris: [...EDITOR_SCHEMES.map((s) => `${s}://${EDITOR_EXTENSION_ID}/oauth`), "http://127.0.0.1/callback"],
75+ client_id: `${base(env)}${path}`,
76+ client_name: c.name,
77+ client_uri: `${base(env)}${c.docs}`,
78+ redirect_uris: c.redirectUris,
6679 grant_types: ["authorization_code", "refresh_token"],
6780 response_types: ["code"],
6881 token_endpoint_auth_method: "none",
@@ -319,7 +332,7 @@
319332 const row = await this.db.prepare("SELECT * FROM oauth_clients WHERE id = ?").bind(id).first<Record<string, string | null>>();
320333 const fresh = row && (row.kind === "registered" || (row.fetched_at && Date.now() - Date.parse(row.fetched_at) < METADATA_TTL_MS));
321334 if (row && (fresh || opts.fetch === false)) return toClient(row);
322- const own = id === editorClientId(this.env) ? editorClientDocument(this.env) : null;
335+ const own = id.startsWith(`${base(this.env)}/`) ? ownClientDocument(this.env, id.slice(base(this.env).length)) : null;
323336 if (opts.fetch === false && !own) return null;
324337 if (!own && !isMetadataClientId(id, localOnly(this.env))) return null;
325338 const doc = own
modifiedapps/diffinery/src/docs/web.tsx+39−2View file
@@ -7,7 +7,9 @@
77 * `curl -fsSL https://diffinery.com/install | bash` and `irm https://diffinery.com/install.ps1 | iex`;
88 * and the extension for VS Code, Cursor and other editors built on VS Code (apps/vscode) at
99 * /-/vscode/diffinery.vsix, with /-/vscode/version.json (which it updates itself from) and its
10- * install scripts at /install-editor and /install-editor.ps1.
10+ * install scripts at /install-editor and /install-editor.ps1; and the plugin for JetBrains IDEs (apps/jetbrains) at
11+ * /-/jetbrains/diffinery-jetbrains.zip, with /-/jetbrains/updatePlugins.xml, the custom plugin repository the IDEs
12+ * install and update it from.
1113 */
1214 import { raw } from "hono/html";
1315 import type { AppContext } from "../auth/principal.js";
@@ -17,7 +19,7 @@
1719 import { fenceHighlighter } from "../web/highlight.js";
1820 import { ICONS } from "../web/assets.js";
1921 import { render, type WebApp } from "../web/ui.js";
20-import { CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH } from "./content.generated.js";
22+import { CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH, JETBRAINS_PLUGIN_DESCRIPTION, JETBRAINS_PLUGIN_SHA256, JETBRAINS_PLUGIN_SINCE_BUILD, JETBRAINS_PLUGIN_VERSION, JETBRAINS_PLUGIN_ZIP_B64 } from "./content.generated.js";
2123 import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js";
2224 import { openApiDocument } from "./reference.js";
2325
@@ -186,6 +188,21 @@
186188 "agents/claude-and-codex": "connect",
187189 };
188190
191+/** The custom plugin repository JetBrains IDEs read: one plugin, the version this deploy serves. */
192+export function jetbrainsRepository(origin: string, fileName: string): string {
193+ const description = JETBRAINS_PLUGIN_DESCRIPTION.replaceAll("https://diffinery.com", origin).replaceAll("]]>", "]]]]><![CDATA[>");
194+ return `<?xml version="1.0" encoding="UTF-8"?>
195+<plugins>
196+ <plugin id="com.diffinery" url="${origin}/-/jetbrains/${fileName}" version="${JETBRAINS_PLUGIN_VERSION}">
197+ <idea-version since-build="${JETBRAINS_PLUGIN_SINCE_BUILD}"/>
198+ <name>Diffinery</name>
199+ <vendor url="${origin}">Diffinery</vendor>
200+ <description><![CDATA[${description}]]></description>
201+ </plugin>
202+</plugins>
203+`;
204+}
205+
189206 export function registerDocs(web: WebApp): void {
190207 web.get("/llms.txt", (c) => c.body(llmsTxt(origin(c)), 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8" }));
191208 web.get("/llms-full.txt", (c) => c.body(llmsFullTxt(origin(c)), 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8" }));
@@ -229,6 +246,26 @@
229246 web.get("/install-editor.sh", (c) => installScript(c, EDITOR_INSTALL_SH));
230247 web.get("/install-editor.ps1", (c) => installScript(c, EDITOR_INSTALL_PS1));
231248
249+ // JetBrains IDEs install the plugin from a custom plugin repository: Settings → Plugins → ⚙ → Manage Plugin
250+ // Repositories → this updatePlugins.xml. They check it for updates like the Marketplace.
251+ const pluginName = `diffinery-jetbrains-${JETBRAINS_PLUGIN_VERSION}.zip`;
252+ let pluginZip: Uint8Array<ArrayBuffer> | undefined;
253+ const pluginBody = (c: AppContext) => {
254+ pluginZip ??= Uint8Array.from(atob(JETBRAINS_PLUGIN_ZIP_B64), (ch) => ch.charCodeAt(0));
255+ return c.body(pluginZip, 200, {
256+ ...PUBLIC,
257+ "content-type": "application/zip",
258+ "content-disposition": `attachment; filename="${pluginName}"`,
259+ "x-diffinery-plugin-version": JETBRAINS_PLUGIN_VERSION,
260+ "x-diffinery-plugin-sha256": JETBRAINS_PLUGIN_SHA256,
261+ });
262+ };
263+ web.get("/-/jetbrains/diffinery-jetbrains.zip", pluginBody);
264+ web.get(`/-/jetbrains/${pluginName}`, pluginBody);
265+ web.get("/-/jetbrains/updatePlugins.xml", (c) =>
266+ c.body(jetbrainsRepository(origin(c), pluginName), 200, { ...PUBLIC, "content-type": "application/xml; charset=utf-8" }),
267+ );
268+
232269 // Pictures for the docs pages (docs/images/), decoded once per isolate.
233270 const images = new Map<string, Uint8Array<ArrayBuffer>>();
234271 web.get("/docs/images/:file", (c) => {
modifiedapps/diffinery/src/http/oauth.ts+3−3View file
@@ -1,7 +1,7 @@
11 import { Hono, type Context } from "hono";
22 import type { Env } from "../env.js";
33 import type { AppVars } from "../auth/principal.js";
4-import { EDITOR_CLIENT_PATH, OAuth, OAuthError, authorizationServerMetadata, editorClientDocument, protectedResourceMetadata } from "../auth/oauth.js";
4+import { OAuth, OAuthError, OWN_CLIENT_PATHS, authorizationServerMetadata, ownClientDocument, protectedResourceMetadata } from "../auth/oauth.js";
55
66 /**
77 * OAuth's machine endpoints for MCP clients (auth/oauth.ts has the flow; the consent page is in
@@ -30,8 +30,8 @@
3030 // Clients that put the resource's path after the well-known name (RFC 8414 §3.1 with an issuer path) get the same document.
3131 oauthApi.get("/.well-known/oauth-authorization-server/mcp", (c) => c.json(authorizationServerMetadata(c.env), 200, DISCOVERY));
3232
33-// The metadata document of Diffinery's own editor extension (apps/vscode), whose client_id is this URL.
34-oauthApi.get(EDITOR_CLIENT_PATH, (c) => c.json(editorClientDocument(c.env), 200, DISCOVERY));
33+// The metadata documents of Diffinery's own editor extension and JetBrains plugin, whose client_ids are these URLs.
34+for (const path of OWN_CLIENT_PATHS) oauthApi.get(path, (c) => c.json(ownClientDocument(c.env, path), 200, DISCOVERY));
3535
3636 const oauthError = (c: Context, e: OAuthError) => {
3737 const headers: Record<string, string> = { ...CORS, ...NO_STORE };
addedapps/diffinery/test/e2e/jetbrains-smoke.mjs+397−0View file
@@ -1,0 +1,397 @@
1+// On-demand check of the JetBrains plugin (apps/jetbrains) in a real IntelliJ IDEA, against a real `wrangler dev`
2+// Worker: "Diffinery" in Get from Version Control, the browser sign-in through Diffinery's OAuth consent page, the
3+// repository list and search, a clone of a private repository with Git asking the plugin for the password, Share
4+// Project on Diffinery, signing in with a pasted token, and signing out. Not part of `pnpm e2e` (it needs an IDE and
5+// a display). The IDE installs the plugin the way people do, from the Worker's plugin repository
6+// (/-/jetbrains/updatePlugins.xml, serving apps/jetbrains/dist), and is driven through JetBrains' Remote Robot server
7+// plugin, whose scripts run inside the IDE:
8+//
9+// (cd apps/jetbrains && ./gradlew dist) && node scripts/build-docs.mjs
10+// IDE_HOME=/path/to/idea-IU-262.x ROBOT=/path/to/robot-server-plugin-0.11.23.zip \
11+// pnpm --filter @diffinery/app exec node test/e2e/jetbrains-smoke.mjs
12+//
13+// It starts its own Xvfb display. SHOTS=dir saves screenshots of each step, and SCALE=2 renders the IDE at 2x. DOCS=1
14+// takes them for the docs: the home folder is /home/ada (it must not exist) and the pictures name diffinery.com, not
15+// the local Worker's address.
16+import { spawn, execFileSync } from "node:child_process";
17+import { createHash, randomBytes } from "node:crypto";
18+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync, copyFileSync } from "node:fs";
19+import { createServer } from "node:net";
20+import { tmpdir } from "node:os";
21+import { join, dirname, resolve } from "node:path";
22+import { fileURLToPath } from "node:url";
23+import assert from "node:assert/strict";
24+import { chromium } from "playwright";
25+
26+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
27+const IDE = process.env.IDE_HOME;
28+const ROBOT = process.env.ROBOT && resolve(process.env.ROBOT);
29+const SHOTS = process.env.SHOTS;
30+const DOCS = process.env.DOCS === "1";
31+const SCALE = Number(process.env.SCALE || (DOCS ? 2 : 1));
32+if (!IDE || !ROBOT) throw new Error("set IDE_HOME and ROBOT");
33+const work = mkdtempSync(join(tmpdir(), "diffinery-jetbrains-"));
34+const hash = (s) => createHash("sha256").update(s).digest("hex");
35+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36+const ADA_SESSION = "ada-jetbrains-session-0123456789abcdef";
37+const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`;
38+const CHARLES_TOKEN = `dfy_${randomBytes(24).toString("hex")}`;
39+let devLog = "";
40+
41+const freePort = () =>
42+ new Promise((res) => {
43+ const s = createServer();
44+ s.listen(0, "127.0.0.1", () => {
45+ const { port } = s.address();
46+ s.close(() => res(port));
47+ });
48+ });
49+async function until(what, fn, timeoutMs = 60_000) {
50+ const end = Date.now() + timeoutMs;
51+ let last;
52+ while (Date.now() < end) {
53+ try {
54+ const v = await fn();
55+ if (v) return v;
56+ } catch (e) {
57+ last = e;
58+ }
59+ await sleep(400);
60+ }
61+ throw new Error(`timed out waiting for ${what}${last ? ` (last error: ${last.message})` : ""}`);
62+}
63+const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
64+const children = [];
65+const cleanup = () => {
66+ for (const c of children.reverse())
67+ try {
68+ process.kill(-c.pid, "SIGTERM");
69+ } catch {}
70+};
71+process.on("exit", cleanup);
72+
73+// ---------------------------------------------------------------- a local Diffinery with ada, charles and repositories
74+const port = await freePort();
75+const base = `http://localhost:${port}`;
76+const persist = join(work, "state");
77+const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command);
78+wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
79+const at = new Date().toISOString();
80+const later = new Date(Date.now() + 86400_000).toISOString();
81+sql(
82+ [
83+ `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`,
84+ `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`,
85+ `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t1', 'ada1', 'e2e', '${hash(ADA_TOKEN)}', '${ADA_TOKEN.slice(0, 10)}', '["admin"]', '${later}', '${at}')`,
86+ `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('cb1', 'charles', 'user', 'Charles', 'charles@example.com', 'active', '${at}')`,
87+ `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t2', 'cb1', 'e2e', '${hash(CHARLES_TOKEN)}', '${CHARLES_TOKEN.slice(0, 10)}', '["write"]', '${later}', '${at}')`,
88+ ].join("; "),
89+);
90+const dev = spawn(
91+ "pnpm",
92+ ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"],
93+ { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
94+);
95+children.push(dev);
96+dev.stdout.on("data", (d) => (devLog += d));
97+dev.stderr.on("data", (d) => (devLog += d));
98+await until("wrangler dev", async () => (await fetch(`${base}/healthz`).catch(() => null))?.ok, 120_000).catch((e) => {
99+ throw new Error(`${e.message}\n${devLog.slice(-3000)}`);
100+});
101+console.log("worker up at", base);
102+
103+const api = (method, path, body, token = ADA_TOKEN) =>
104+ fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: body && JSON.stringify(body) }).then(async (r) =>
105+ r.ok ? r.json() : Promise.reject(new Error(`${method} ${path}: ${r.status} ${await r.text()}`)),
106+ );
107+await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "The analytical engine, in TypeScript" });
108+await api("POST", "/user/repos", { name: "notes", private: false, description: "Notes on the engine, and drafts of the next paper" });
109+await api("POST", "/user/repos", { name: "engine-drawings", private: true }, CHARLES_TOKEN);
110+const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString();
111+const seed = join(work, "seed");
112+mkdirSync(seed);
113+git(seed, "init", "-q", "-b", "main");
114+writeFileSync(join(seed, "README.md"), "# hello-editor\n\nCloned from Diffinery in a JetBrains IDE.\n");
115+git(seed, "add", ".");
116+git(seed, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "First commit");
117+git(seed, "push", "-q", `http://x:${ADA_TOKEN}@localhost:${port}/ada/hello-editor.git`, "main");
118+
119+// ---------------------------------------------------------------- the IDE
120+const home = DOCS ? "/home/ada" : join(work, "home");
121+if (DOCS && existsSync(home)) throw new Error(`${home} exists already`);
122+const ide = join(work, "ide");
123+for (const d of ["config/options", "system", "plugins", "log"]) mkdirSync(join(ide, d), { recursive: true });
124+mkdirSync(home, { recursive: true });
125+execFileSync("unzip", ["-q", ROBOT, "-d", join(ide, "plugins")]);
126+// Git saves nothing itself here: every password must come from the plugin.
127+writeFileSync(join(home, ".gitconfig"), "[user]\n\tname = Ada\n\temail = ada@example.com\n[credential]\n\thelper =\n");
128+// The server, as someone would set it under Settings → Version Control → Diffinery for a self-hosted Diffinery.
129+// No desktop keyring under Xvfb: keep passwords in the IDE's own KeePass file, as people without one do.
130+writeFileSync(join(ide, "config/options/security.xml"), `<application>\n <component name="PasswordSafe">\n <option name="PROVIDER" value="KEEPASS" />\n </component>\n</application>\n`);
131+writeFileSync(join(ide, "config/options/diffinery.xml"), `<application>\n <component name="Diffinery">\n <option name="server" value="${base}" />\n </component>\n</application>\n`);
132+// A local repository with a commit and no remote, for Share Project on Diffinery.
133+const engine = join(home, "difference-engine");
134+mkdirSync(engine, { recursive: true });
135+git(engine, "init", "-q", "-b", "main");
136+writeFileSync(join(engine, "engine.ts"), "export const add = (a: number, b: number) => a + b;\n");
137+git(engine, "add", ".");
138+git(engine, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "Start the engine");
139+// An IDE project already (so opening it skips the New Project wizard), with .idea kept out of Git.
140+mkdirSync(join(engine, ".idea"));
141+writeFileSync(join(engine, ".git", "info", "exclude"), ".idea/\n");
142+// The browser the IDE opens: record the URL, as a person's browser would receive it.
143+const bin = join(work, "bin");
144+mkdirSync(bin);
145+const opened = join(work, "opened.txt");
146+writeFileSync(join(bin, "xdg-open"), `#!/bin/sh\necho "$1" >> ${opened}\n`);
147+chmodSync(join(bin, "xdg-open"), 0o755);
148+const robotPort = await freePort();
149+writeFileSync(join(ide, "idea.properties"), ["config", "system", "plugins", "log"].map((d) => `idea.${d}.path=${join(ide, d)}`).join("\n") + "\n");
150+writeFileSync(
151+ join(ide, "idea.vmoptions"),
152+ readFileSync(join(IDE, "bin", "idea64.vmoptions"), "utf8") +
153+ [
154+ `-Drobot-server.port=${robotPort}`,
155+ "-Djb.consents.confirmation.enabled=false",
156+ "-Djb.privacy.policy.text=<!--999.999-->",
157+ "-Didea.trust.all.projects=true",
158+ "-Dide.show.tips.on.startup.default.value=false",
159+ "-Didea.initially.ask.config=never",
160+ "-Dide.newUsersOnboarding=false",
161+ `-Dsun.java2d.uiScale=${SCALE}`,
162+ `-Duser.home=${home}`,
163+ ].join("\n") +
164+ "\n",
165+);
166+const display = `:${100 + Math.floor(Math.random() * 400)}`;
167+console.log(`IDE on display ${display}, robot server on port ${robotPort}`);
168+const xvfb = spawn("Xvfb", [display, "-screen", "0", `${1600 * SCALE}x${1000 * SCALE}x24`, "-nolisten", "tcp"], { stdio: "ignore", detached: true });
169+children.push(xvfb);
170+await sleep(1000);
171+const env = { ...process.env, HOME: home, DISPLAY: display, PATH: `${bin}:${process.env.PATH}`, BROWSER: join(bin, "xdg-open"), IDEA_PROPERTIES: join(ide, "idea.properties"), IDEA_VM_OPTIONS: join(ide, "idea.vmoptions"), JAVA_TOOL_OPTIONS: "", GIT_TERMINAL_PROMPT: "0" };
172+// The plugin comes from this Worker's plugin repository (/-/jetbrains/updatePlugins.xml), as for people: through
173+// Settings → Plugins (the default), or INSTALL=cli for the `idea installPlugins` command.
174+const FEED = `${base}/-/jetbrains/updatePlugins.xml`;
175+const CLI_INSTALL = process.env.INSTALL === "cli";
176+if (CLI_INSTALL) {
177+ const installed = execFileSync(join(IDE, "bin", "idea.sh"), ["installPlugins", "com.diffinery", FEED], { env, stdio: "pipe", timeout: 300_000 }).toString();
178+ assert.ok(existsSync(join(ide, "plugins", "diffinery-jetbrains")), `installPlugins didn't install the plugin:\n${installed.slice(-2000)}`);
179+}
180+let ideLog = "";
181+const ideLogText = () => (existsSync(join(ide, "log/idea.log")) ? readFileSync(join(ide, "log/idea.log"), "utf8") : "");
182+const launched = ideLogText().length; // installPlugins logged before this
183+const idea = spawn(join(IDE, "bin", "idea.sh"), [], { env, stdio: ["ignore", "pipe", "pipe"], detached: true });
184+children.push(idea);
185+idea.stdout.on("data", (d) => (ideLog += d));
186+idea.stderr.on("data", (d) => (ideLog += d));
187+
188+/** A screenshot of the whole display, or of the window titled `title`. */
189+async function shot(name, title) {
190+ if (!SHOTS) return;
191+ mkdirSync(SHOTS, { recursive: true });
192+ const file = join(SHOTS, `${name}.png`);
193+ // Tips the IDE shows over the window aren't part of the picture.
194+ await js(`var b = button("Got It"); if (b) b.doClick(); "ok"`);
195+ if (DOCS) await js(`var a = showing(); for (var i = 0; i < a.length; i++) if (a[i] instanceof javax.swing.JLabel && text(a[i]).indexOf(${JSON.stringify(new URL(base).host)}) >= 0) a[i].setText(text(a[i]).replace(${JSON.stringify(new URL(base).host)}, "diffinery.com")); "ok"`);
196+ await sleep(300);
197+ execFileSync("import", ["-display", display, "-window", "root", file]);
198+ if (title) {
199+ const b = JSON.parse(await js(`var w = windowTitled(${JSON.stringify(title)}); var p = w.getLocationOnScreen(); var s = w.getSize(); JSON.stringify([p.x, p.y, s.width, s.height])`));
200+ const sx = SCALE;
201+ execFileSync("convert", [file, "-crop", `${b[2] * sx}x${b[3] * sx}+${b[0] * sx}+${b[1] * sx}`, "+repage", file]);
202+ }
203+}
204+
205+// Scripts run inside the IDE (Rhino, with the IDE's classes). The prelude finds components in showing windows.
206+const PRELUDE = `
207+var App = com.intellij.openapi.application.ApplicationManager.getApplication();
208+function walk(c, out) { out.push(c); if (c instanceof java.awt.Container) { var k = c.getComponents(); for (var i = 0; i < k.length; i++) walk(k[i], out); } return out; }
209+function showing() { var out = []; var ws = java.awt.Window.getWindows(); for (var i = 0; i < ws.length; i++) if (ws[i].isShowing()) walk(ws[i], out); return out; }
210+function find(pred) { var a = showing(); for (var i = 0; i < a.length; i++) if (a[i].isShowing() && pred(a[i])) return a[i]; return null; }
211+function cls(c) { return String(c.getClass().getName()); }
212+function text(c) { try { return String(c.getText()); } catch (e) { return ""; } }
213+function button(t) { return find(function (c) { return c instanceof javax.swing.AbstractButton && text(c).replace(/<[^>]+>/g, "") === t; }); }
214+function windowTitled(t) { var ws = java.awt.Window.getWindows(); for (var i = 0; i < ws.length; i++) { var w = ws[i]; if (w.isShowing() && w instanceof java.awt.Dialog && String(w.getTitle()) === t) return w; } return null; }
215+function later(f) { App.invokeLater(new java.lang.Runnable({ run: f }), com.intellij.openapi.application.ModalityState.any()); }
216+function click(t) { var b = button(t); if (!b) throw new Error("no button " + t); later(function () { b.doClick(); }); return "ok"; }
217+function pluginClass(name) { return com.intellij.ide.plugins.PluginManagerCore.getPlugin(com.intellij.openapi.extensions.PluginId.getId("com.diffinery")).getPluginClassLoader().loadClass(name); }
218+function accounts() { return pluginClass("com.diffinery.jetbrains.Accounts").getMethod("get").invoke(null); }
219+function runAction(id, component) { var am = com.intellij.openapi.actionSystem.ActionManager.getInstance(); later(function () { am.tryToExecute(am.getAction(id), null, component || null, "DiffinerySmoke", true); }); return "ok"; }
220+function repoList() { return find(function (c) { return c instanceof javax.swing.JList && String(c.getName()) === "diffinery.repositories"; }); }
221+function repoNames() { var l = repoList(); if (!l) return "[]"; var out = []; for (var i = 0; i < l.getModel().getSize(); i++) out.push(String(l.getModel().getElementAt(i)).match(/fullName=([^,]+)/)[1]); return JSON.stringify(out); }
222+`;
223+/** Runs a script in the IDE (on the UI thread unless `edt` is false) and returns its result as a string. */
224+async function js(script, edt = true) {
225+ const r = await fetch(`http://127.0.0.1:${robotPort}/js/retrieveAny`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ script: `${PRELUDE}\nString(eval(${JSON.stringify(script)}))`, runInEdt: edt }) });
226+ const j = await r.json();
227+ if (j.status !== "SUCCESS") throw new Error(`IDE script failed: ${j.message ?? ""} ${String(j.exception ?? "").slice(0, 800)}`);
228+ // A Java-serialized String: 0xACED0005, then TC_STRING (0x74) with a 2-byte length, or TC_LONGSTRING (0x7C) with 8.
229+ const b = Buffer.from(j.bytes.map((x) => x & 255));
230+ if (b[4] === 0x74) return b.subarray(7, 7 + b.readUInt16BE(5)).toString("utf8");
231+ if (b[4] === 0x7c) return b.subarray(13, 13 + Number(b.readBigUInt64BE(5))).toString("utf8");
232+ throw new Error("IDE script didn't return a string");
233+}
234+
235+async function fail(e) {
236+ await shot("failed").catch(() => {});
237+ console.error(e);
238+ // KEEP=1 leaves the IDE running to look around in (its robot server's port is printed at the start).
239+ if (process.env.KEEP) return;
240+ console.error("--- IDE log tail ---\n" + (existsSync(join(ide, "log/idea.log")) ? readFileSync(join(ide, "log/idea.log"), "utf8").split("\n").filter((l) => /diffinery|ERROR|SEVERE/i.test(l)).slice(-40).join("\n") : ideLog.slice(-3000)));
241+ cleanup();
242+ process.exit(1);
243+}
244+process.on("unhandledRejection", fail);
245+process.on("uncaughtException", fail);
246+
247+// On its first start after installPlugins the IDE asks whether to trust the plugin, since it isn't JetBrains': a
248+// person clicks Accept, here Enter (its default button) through java.awt.Robot, until the IDE loads the plugin.
249+writeFileSync(join(work, "PressEnter.java"), "public class PressEnter { public static void main(String[] a) throws Exception { var r = new java.awt.Robot(); r.keyPress(10); r.keyRelease(10); r.delay(200); } }\n");
250+if (CLI_INSTALL) await until("the IDE to load the plugin", async () => {
251+ const log = ideLogText().slice(launched);
252+ if (/Loaded custom plugins: .*Diffinery \(/.test(log)) return true;
253+ if (/Disabled plugins|Marking plugins 'disabled': \[[^\]]*com\.diffinery/.test(log)) throw new Error("the IDE disabled the plugin");
254+ if (/Computed user scale factor/.test(log)) execFileSync(join(IDE, "jbr/bin/java"), [join(work, "PressEnter.java")], { env: { ...env, JAVA_TOOL_OPTIONS: "" }, stdio: "ignore" });
255+ await sleep(2000);
256+}, 180_000);
257+await until("the IDE's robot server", async () => (await fetch(`http://127.0.0.1:${robotPort}/hello`).catch(() => null))?.ok, 180_000);
258+await until("the welcome screen", async () => (await js(`com.intellij.openapi.wm.impl.welcomeScreen.WelcomeFrame.getInstance() != null`)) === "true", 120_000);
259+const diffineryPlugin = `com.intellij.ide.plugins.PluginManagerCore.getPlugin(com.intellij.openapi.extensions.PluginId.getId("com.diffinery"))`;
260+if (!CLI_INSTALL) {
261+ // 0. Settings → Plugins → ⚙ → Manage Plugin Repositories → the feed; then Marketplace → "Diffinery" → Install.
262+ assert.equal(await js(`${diffineryPlugin} == null`), "true");
263+ await js(`com.intellij.openapi.updateSettings.impl.UpdateSettings.getInstance().getStoredPluginHosts().add(${JSON.stringify(FEED)}); "ok"`);
264+ await js(`later(function () { com.intellij.openapi.options.ShowSettingsUtil.getInstance().showSettingsDialog(null, com.intellij.ide.plugins.PluginManagerConfigurable); }); "ok"`);
265+ const tabs = `find(function (c) { return c instanceof javax.swing.JTabbedPane && cls(c).indexOf("TabbedPaneHeaderComponent") >= 0; })`;
266+ await until("the Plugins page", async () => (await js(`${tabs} != null`)) === "true", 30_000);
267+ assert.equal(await js(`${tabs}.getTitleAt(0)`), "Marketplace");
268+ await js(`${tabs}.setSelectedIndex(0); "ok"`);
269+ const search = `find(function (c) { return c instanceof com.intellij.ui.SearchTextField && cls(c).indexOf("PluginsTab") >= 0; })`;
270+ await until("the Marketplace search", async () => (await js(`${search} != null`)) === "true", 30_000);
271+ await js(`${search}.setText("Diffinery"); "ok"`);
272+ await until("Diffinery in the Marketplace results", async () => (await js(`button("Install") != null`)) === "true", 60_000);
273+ await sleep(1500);
274+ await shot("0-plugins", "Settings");
275+ await js(`click("Install")`);
276+ // The IDE warns that the plugin isn't JetBrains'; a person accepts.
277+ await until("the third-party plugin notice", async () => (await js(`windowTitled("Third-Party Plugins Notice") != null`)) === "true", 30_000);
278+ await js(`click("Accept")`);
279+ await until("the plugin installed", async () => (await js(`button("Installed") != null`)) === "true", 60_000);
280+ await js(`click("OK")`);
281+ // It loads without restarting the IDE.
282+ await until("the plugin loaded", async () => (await js(`var p = ${diffineryPlugin}; p != null && com.intellij.ide.plugins.PluginManagerCore.isLoaded(p.getPluginId())`)) === "true", 60_000);
283+}
284+console.log("IDE up, plugin", await js(`${diffineryPlugin}.getVersion()`));
285+await sleep(3000);
286+
287+// 1. Get from Version Control lists Diffinery next to GitHub and GitLab.
288+await js(`runAction("Vcs.VcsClone")`);
289+// "Get from Version Control" until 2025.x, "Clone Repository" since.
290+const CLONE = await until("Get from Version Control", async () => await js(`var w = windowTitled("Get from Version Control") || windowTitled("Clone Repository"); w ? w.getTitle() : ""`), 30_000);
291+const sources = JSON.parse(
292+ await js(`var l = find(function (c) { return cls(c).indexOf("VcsCloneDialogExtensionList") >= 0; }); var out = []; var m = l.getModel(); for (var i = 0; i < m.getSize(); i++) { var e = m.getElementAt(i); out.push(String(e.getName ? e.getName() : e)); } JSON.stringify(out)`),
293+);
294+console.log("Get from Version Control:", sources);
295+assert.ok(sources.includes("Diffinery"), "Diffinery is a source");
296+await js(`var l = find(function (c) { return cls(c).indexOf("VcsCloneDialogExtensionList") >= 0; }); var m = l.getModel(); for (var i = 0; i < m.getSize(); i++) if (String(m.getElementAt(i).getName()) === "Diffinery") l.setSelectedIndex(i); "ok"`);
297+await until("the sign-in page", async () => (await js(`button("Sign In with Browser…") != null`)) === "true", 20_000);
298+await sleep(500);
299+await shot("1-get-from-vcs", CLONE);
300+
301+// 2. Signing in: the IDE opens Diffinery's consent page in the browser and listens on 127.0.0.1 for the answer.
302+await js(`click("Sign In with Browser…")`);
303+const authorize = await until("the IDE to open the browser", async () => existsSync(opened) && readFileSync(opened, "utf8").trim().split("\n").pop(), 30_000);
304+const au = new URL(authorize);
305+assert.equal(au.origin + au.pathname, `${base}/oauth/authorize`);
306+assert.equal(au.searchParams.get("client_id"), `${base}/oauth/clients/jetbrains.json`);
307+assert.match(au.searchParams.get("redirect_uri"), /^http:\/\/127\.0\.0\.1:\d+\/callback$/);
308+const browser = await chromium.launch();
309+const page = await (await browser.newContext()).newPage();
310+await page.context().addCookies([{ name: "__Host-dfy_session", value: ADA_SESSION, url: base }]).catch(async () => {
311+ await page.goto(`${base}/login`);
312+ await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION);
313+});
314+await page.goto(authorize);
315+const consent = await page.textContent("main");
316+assert.match(consent, /Connect Diffinery plugin for JetBrains IDEs/);
317+assert.match(consent, /Sends you back to\s*an app on this computer/);
318+if (SHOTS) await page.screenshot({ path: join(SHOTS, "2-consent.png"), fullPage: true });
319+await page.click('button[value="approve"]');
320+await page.waitForURL(/^http:\/\/127\.0\.0\.1:\d+\/callback/);
321+assert.match(await page.textContent("body"), /You're signed in to Diffinery/);
322+if (SHOTS) await page.screenshot({ path: join(SHOTS, "2b-signed-in.png") });
323+await browser.close();
324+console.log("browser: consent approved, signed-in page shown");
325+
326+// 3. Signed in: the page lists ada's repositories; search narrows them.
327+await until("ada's repositories", async () => JSON.parse(await js(`repoNames()`)).length >= 2, 30_000);
328+const repos = JSON.parse(await js(`repoNames()`));
329+console.log("repositories:", repos);
330+assert.deepEqual([...repos].sort(), ["ada/hello-editor", "ada/notes"]);
331+await sleep(500);
332+await shot("3-repositories", CLONE);
333+await js(`var f = find(function (c) { return c instanceof com.intellij.ui.SearchTextField; }); f.setText("hello"); "ok"`);
334+await until("the search result", async () => JSON.stringify(JSON.parse(await js(`repoNames()`))) === JSON.stringify(["ada/hello-editor"]), 20_000);
335+const directory = await until("the directory", async () => {
336+ const d = await js(`var l = repoList(); l.setSelectedIndex(0); var f = find(function (c) { return c instanceof com.intellij.openapi.ui.TextFieldWithBrowseButton; }); f ? f.getText() : ""`);
337+ return d.endsWith("hello-editor") && d;
338+});
339+assert.equal(directory, join(home, "IdeaProjects", "hello-editor"));
340+await sleep(300);
341+await shot("4-search", CLONE);
342+
343+// 4. Clone: Git asks the plugin for the password (no prompt), and the IDE opens the project.
344+await until("the Clone button enabled", async () => (await js(`var b = button("Clone"); b != null && b.isEnabled()`)) === "true", 10_000);
345+await js(`click("Clone")`);
346+const readme = join(directory, "README.md");
347+await until("the clone on disk", async () => existsSync(readme), 120_000);
348+assert.match(readFileSync(readme, "utf8"), /Cloned from Diffinery/);
349+assert.match(git(directory, "remote", "get-url", "origin"), new RegExp(`^${base}/ada/hello-editor\\.git`));
350+console.log("cloned:", directory);
351+await until("the project window", async () => (await js(`var ps = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); var n = []; for (var i = 0; i < ps.length; i++) n.push(String(ps[i].getName())); n.join(",")`)).includes("hello-editor"), 120_000);
352+await sleep(4000);
353+await shot("5-opened");
354+
355+// 5. Share Project on Diffinery: a local repository with no remote becomes ada/difference-engine, pushed.
356+await js(`later(function () { com.intellij.ide.impl.ProjectUtil.openOrImport(java.nio.file.Paths.get(${JSON.stringify(engine)}), null, true); }); "ok"`);
357+await until("the difference-engine project", async () => (await js(`var ps = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); var n = []; for (var i = 0; i < ps.length; i++) n.push(String(ps[i].getName())); n.join(",")`)).includes("difference-engine"), 120_000);
358+await until("Git in the project", async () => (await js(`var ps = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); var p = null; for (var i = 0; i < ps.length; i++) if (String(ps[i].getName()) === "difference-engine") p = ps[i]; String(pluginClass("git4idea.repo.GitRepositoryManager").getMethod("getInstance", com.intellij.openapi.project.Project).invoke(null, p).getRepositories().size())`, false)) === "1", 60_000);
359+await js(`var ps = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); var p = null; for (var i = 0; i < ps.length; i++) if (String(ps[i].getName()) === "difference-engine") p = ps[i]; runAction("Diffinery.Share", com.intellij.openapi.wm.WindowManager.getInstance().getFrame(p).getRootPane())`);
360+await until("the Share dialog", async () => (await js(`windowTitled("Share Project on Diffinery") != null`)) === "true", 30_000);
361+assert.equal(await js(`var f = find(function (c) { return c instanceof com.intellij.ui.components.JBTextField && c.isShowing() && javax.swing.SwingUtilities.getWindowAncestor(c) === windowTitled("Share Project on Diffinery"); }); f.getText()`), "difference-engine");
362+await shot("6-share", "Share Project on Diffinery");
363+await js(`click("Share")`);
364+const pushed = await until("the pushed branch on Diffinery", async () => (await api("GET", "/repos/ada/difference-engine/branches").catch(() => null))?.find((b) => b.name === "main"), 60_000);
365+assert.equal(pushed.commit.sha, git(engine, "rev-parse", "HEAD").trim());
366+assert.equal((await api("GET", "/repos/ada/difference-engine")).private, true);
367+assert.match(git(engine, "remote", "get-url", "origin").trim(), /\/ada\/difference-engine\.git$/);
368+console.log("shared: ada/difference-engine, private, main pushed");
369+await sleep(1500);
370+await shot("7-shared");
371+
372+// 6. Signing out disconnects the IDE on Diffinery too.
373+await js(`accounts().signOut(); "ok"`, false);
374+const grants = JSON.parse(wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--json", "--command", "SELECT revoked_at FROM oauth_grants").toString())[0].results;
375+assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked");
376+assert.equal(await js(`String(accounts().account())`, false), "null");
377+console.log("signed out: connection revoked");
378+
379+// 7. Signing in with a pasted token (Clone from Diffinery opens Get from Version Control on the Diffinery page).
380+await js(`runAction("Diffinery.Clone")`);
381+await until("the sign-in page", async () => (await js(`button("Sign In with Browser…") != null`)) === "true", 30_000);
382+await js(`var l = find(function (c) { return c instanceof com.intellij.ui.components.ActionLink && text(c) === "Use an access token…"; }); later(function () { l.doClick(); }); "ok"`);
383+await until("the token dialog", async () => (await js(`windowTitled("Sign In to Diffinery with a Token") != null`)) === "true", 20_000);
384+await js(`var f = find(function (c) { return c instanceof javax.swing.JPasswordField; }); f.setText(${JSON.stringify(CHARLES_TOKEN)}); "ok"`);
385+await js(`var w = windowTitled("Sign In to Diffinery with a Token"); var ok = null; var a = walk(w, []); for (var i = 0; i < a.length; i++) if (a[i] instanceof javax.swing.JButton && text(a[i]) === "OK") ok = a[i]; later(function () { ok.doClick(); }); "ok"`);
386+await until("charles's repositories", async () => JSON.parse(await js(`repoNames()`)).includes("charles/engine-drawings"), 30_000);
387+assert.equal(await js(`accounts().account().login()`, false), "charles");
388+console.log("token sign-in: charles's repositories listed");
389+await js(`click("Cancel")`);
390+
391+await js(`App.exit(true, true, false); "ok"`).catch(() => {});
392+await sleep(2000);
393+cleanup();
394+if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true });
395+if (DOCS) rmSync(home, { recursive: true, force: true });
396+console.log("jetbrains smoke test passed");
397+process.exit(0);
modifiedapps/diffinery/test/e2e/oauth.test.mjs+27−0View file
@@ -400,3 +400,30 @@
400400 assert.match(await text(ada.page), /didn't register\. Nothing was shared/);
401401 await ada.context.close();
402402 });
403+
404+test("OAuth: Diffinery's own plugin for JetBrains IDEs (apps/jetbrains)", async () => {
405+ const ada = await adaBrowser();
406+ const { verifier, challenge } = pkce();
407+ const client_id = `${base}/oauth/clients/jetbrains.json`;
408+ const doc = await (await fetch(client_id)).json();
409+ assert.equal(doc.client_id, client_id);
410+ assert.equal(doc.client_name, "Diffinery plugin for JetBrains IDEs");
411+ assert.deepEqual(doc.redirect_uris, ["http://127.0.0.1/callback"]);
412+ const authorize = (redirect_uri) => `${base}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id, redirect_uri, code_challenge: challenge, code_challenge_method: "S256", state: "jb", scope: "write" })}`;
413+ // The IDE listens on a loopback port of its choosing.
414+ await ada.page.goto(authorize("http://127.0.0.1:51234/callback"));
415+ assert.match(await text(ada.page), /Connect Diffinery plugin for JetBrains IDEs to Diffinery/);
416+ assert.match(await text(ada.page), /Sends you back to\s*an app on this computer/);
417+ const cb = await consent(ada, authorize("http://127.0.0.1:51234/callback"), { scope: "write" });
418+ assert.equal(cb.port, "51234");
419+ assert.equal(cb.searchParams.get("state"), "jb");
420+ const t = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb.searchParams.get("code"), client_id, code_verifier: verifier, redirect_uri: "http://127.0.0.1:51234/callback" });
421+ assert.equal(t.status, 200, await t.clone().text());
422+ assert.equal((await whoami((await t.json()).access_token)).login, "ada");
423+ // Only loopback: no editor schemes, no other hosts.
424+ for (const bad of ["vscode://codician.diffinery/oauth", "http://example.com/callback"]) {
425+ await ada.page.goto(authorize(bad));
426+ assert.match(await text(ada.page), /didn't register\. Nothing was shared/, bad);
427+ }
428+ await ada.context.close();
429+});
modifiedapps/diffinery/test/unit/docs.test.ts+22−0View file
@@ -228,6 +228,28 @@
228228 expect(ps1).toContain("/-/cli/diffinery.mjs");
229229 });
230230
231+ it("serves the JetBrains plugin and the plugin repository the IDEs install and update it from", async () => {
232+ const xml = await get("/-/jetbrains/updatePlugins.xml");
233+ expect(xml.status).toBe(200);
234+ expect(xml.headers.get("content-type")).toContain("application/xml");
235+ const body = await xml.text();
236+ const plugin = body.match(/<plugin id="com\.diffinery" url="([^"]+)" version="([^"]+)">/);
237+ expect(plugin, body).not.toBeNull();
238+ const [, url, version] = plugin!;
239+ expect(url).toBe(`${ORIGIN}/-/jetbrains/diffinery-jetbrains-${version}.zip`);
240+ expect(body).toMatch(/<idea-version since-build="\d+"\/>/);
241+ expect(body).toContain(`${ORIGIN}/docs/repositories/clone-in-jetbrains-ides`);
242+ for (const path of [new URL(url).pathname, "/-/jetbrains/diffinery-jetbrains.zip"]) {
243+ const zip = await get(path);
244+ expect(zip.status, path).toBe(200);
245+ expect(zip.headers.get("x-diffinery-plugin-version")).toBe(version);
246+ const bytes = new Uint8Array(await zip.arrayBuffer());
247+ expect(bytes.slice(0, 4)).toEqual(new Uint8Array([0x50, 0x4b, 0x03, 0x04]));
248+ const sha = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (b) => b.toString(16).padStart(2, "0")).join("");
249+ expect(zip.headers.get("x-diffinery-plugin-sha256")).toBe(sha);
250+ }
251+ });
252+
231253 it("serves the screenshots in docs/images", async () => {
232254 const res = await get("/docs/images/connected-apps.webp");
233255 expect(res.status).toBe(200);
addedapps/jetbrains/.gitignore+6−0View file
@@ -1,0 +1,6 @@
1+.gradle/
2+.kotlin/
3+build/
4+.intellijPlatform/
5+# The plugin zip diffinery.com serves is checked in (see README.md).
6+!dist/
addedapps/jetbrains/CHANGELOG.md+10−0View file
@@ -1,0 +1,10 @@
1+# Changelog
2+
3+## 0.1.0
4+
5+- **Diffinery** in **Get from Version Control**: your repositories and your organizations', search across every
6+ repository you can read, or paste `owner/name` or a link.
7+- Sign in with your passkey in the browser (OAuth with PKCE), or with an access token.
8+- Git asks the plugin for the password for the server you're signed in to; the token renews itself.
9+- **Git → Share Project on Diffinery…** creates a repository and pushes the current branch.
10+- **Settings → Version Control → Diffinery** for the account and the server.
addedapps/jetbrains/PUBLISHING.md+34−0View file
@@ -1,0 +1,34 @@
1+# Publishing to the JetBrains Marketplace
2+
3+Not done yet: for now the plugin is served only from diffinery.com (`/-/jetbrains/updatePlugins.xml`, see
4+[README.md](README.md)). These are the steps for when Diffinery goes public.
5+
6+## Before the first upload
7+
8+1. Add a `LICENSE` file; the Marketplace asks for a license and shows it.
9+2. Create a vendor profile for Diffinery at https://plugins.jetbrains.com (an organization, so more than one person
10+ can upload), and a permanent token under **My Tokens** for uploads from the command line.
11+3. Upload the first version by hand (**Upload plugin** on the Marketplace), choosing the license and the
12+ categories (Version Controls). The plugin id `com.diffinery` is what the server's plugin repository and the
13+ docs use, so keep it. JetBrains reviews the first upload, which takes a few working days.
14+4. People who installed from diffinery.com's plugin repository get Marketplace updates too (the IDE takes the highest
15+ version from any repository), so keep the two on the same version.
16+
17+## Listing
18+
19+| Field | Value |
20+| --- | --- |
21+| Name | Diffinery (`<name>` in `plugin.xml`) |
22+| Description | `<description>` in `src/main/resources/META-INF/plugin.xml` |
23+| Icon | `src/main/resources/META-INF/pluginIcon.svg` and `pluginIcon_dark.svg` |
24+| Change notes | `CHANGELOG.md` (add `changeNotes` to `pluginConfiguration` in `build.gradle.kts` to show it) |
25+| Compatibility | `since-build` 243 (2024.3), no upper bound; the Marketplace runs the Plugin Verifier on each upload |
26+| Screenshots | `apps/diffinery/docs/images/jetbrains-*.webp` (taken by `jetbrains-smoke.mjs` with `DOCS=1`) |
27+
28+## Each release
29+
30+1. Release as in [README.md](README.md#release).
31+2. `./gradlew publishPlugin` with the token in `ORG_GRADLE_PROJECT_intellijPlatformPublishingToken` (set
32+ `intellijPlatform { publishing { token = providers.gradleProperty("intellijPlatformPublishingToken") } }` first),
33+ or upload `dist/diffinery-jetbrains-<version>.zip` on the plugin's Marketplace page.
34+3. Deploying Diffinery keeps diffinery.com's plugin repository on the same version.
addedapps/jetbrains/README.md+53−0View file
@@ -1,0 +1,53 @@
1+# Diffinery plugin for JetBrains IDEs
2+
3+Adds **Diffinery** to **Get from Version Control** in IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other
4+JetBrains IDEs (2024.3 and later), with the browser sign-in Diffinery's other apps use (OAuth with PKCE on a
5+`127.0.0.1` redirect, client `/oauth/clients/jetbrains.json`). Git asks the plugin for the password when it talks to
6+the server, and **Git → Share Project on Diffinery…** publishes a local repository. The guide people read is
7+[Clone in JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides).
8+
9+It's plain Java (no Kotlin), against the platform's stable API only: `./gradlew verifyPlugin` runs JetBrains' Plugin
10+Verifier against IntelliJ IDEA 2024.3, 2025.2 and the version the plugin builds with, and fails on internal API use.
11+
12+## Build
13+
14+Gradle 9 through the wrapper, with a JDK 21 or later (the IDE's own JetBrains Runtime works):
15+
16+```sh
17+./gradlew dist # builds and copies the plugin to dist/diffinery-jetbrains-<version>.zip
18+./gradlew test # unit tests
19+./gradlew verifyPlugin
20+```
21+
22+`-PideaHome=/path/to/idea` builds against an IDE already on disk instead of downloading IntelliJ IDEA, and
23+`-Porg.gradle.java.installations.paths=/path/to/idea/jbr` uses its runtime as the JDK.
24+
25+## How diffinery.com serves it
26+
27+`dist/` is checked in, because Diffinery's server build has no JDK. `apps/diffinery/scripts/build-docs.mjs` reads it
28+through `scripts/plugin.mjs`, which refuses a zip whose version isn't `pluginVersion` in `gradle.properties` or that
29+was built from other sources than `src/main` now (the build puts a fingerprint of them in the jar). So after changing
30+the plugin, run `./gradlew dist` and commit the new zip with the change; the zip is byte-for-byte reproducible.
31+
32+The Worker serves:
33+
34+| Path | What |
35+| --- | --- |
36+| `/-/jetbrains/updatePlugins.xml` | The custom plugin repository the IDEs install and update from |
37+| `/-/jetbrains/diffinery-jetbrains-<version>.zip` | That version |
38+| `/-/jetbrains/diffinery-jetbrains.zip` | The current version, for **Install Plugin from Disk…** |
39+
40+## Release
41+
42+1. Bump `pluginVersion` in `gradle.properties` and add a `CHANGELOG.md` entry.
43+2. `./gradlew test verifyPlugin dist`, then run the real-IDE check (below).
44+3. Commit with the new `dist/` zip. Once deployed, IDEs with the plugin repository offer the update.
45+
46+## Real-IDE check
47+
48+`apps/diffinery/test/e2e/jetbrains-smoke.mjs` drives IntelliJ IDEA against a local Worker through JetBrains' Remote
49+Robot server: it installs the plugin from the Worker's plugin repository in **Settings → Plugins**, signs in through
50+the consent page in Chromium, lists, searches and clones a private repository, shares a project, signs out, and signs
51+in with a token. See the comment at its top for how to run it. `DOCS=1` retakes the pictures in the guide.
52+
53+Publishing on the JetBrains Marketplace waits until Diffinery is public: see [PUBLISHING.md](PUBLISHING.md).
addedapps/jetbrains/build.gradle.kts+93−0View file
@@ -1,0 +1,93 @@
1+import java.security.MessageDigest
2+import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
3+import org.jetbrains.intellij.platform.gradle.TestFrameworkType
4+
5+plugins {
6+ java
7+ id("org.jetbrains.intellij.platform") version "2.19.0"
8+}
9+
10+group = "com.diffinery"
11+version = providers.gradleProperty("pluginVersion").get()
12+
13+repositories {
14+ intellijPlatform {
15+ localPlatformArtifacts()
16+ defaultRepositories()
17+ }
18+ // Maven Central through JetBrains' cache: Central rate-limits busy networks with 429s.
19+ maven("https://cache-redirector.jetbrains.com/maven-central")
20+}
21+
22+dependencies {
23+ intellijPlatform {
24+ val local = providers.gradleProperty("ideaHome").orNull
25+ if (local != null) local(local) else intellijIdea(providers.gradleProperty("platformVersion"))
26+ bundledPlugin("Git4Idea")
27+ bundledModule("intellij.platform.vcs.dvcs")
28+ bundledModule("intellij.platform.vcs.dvcs.impl")
29+ bundledModule("intellij.platform.vcs.impl")
30+ testFramework(TestFrameworkType.Platform)
31+ }
32+ testImplementation("junit:junit:4.13.2")
33+ testImplementation("org.opentest4j:opentest4j:1.3.0")
34+}
35+
36+// Compiled with the JDK the IDE runs on, for Java 21: IDEs from 2024.3 on run it.
37+tasks.withType<JavaCompile>().configureEach { options.release = 21 }
38+
39+intellijPlatform {
40+ buildSearchableOptions = false
41+ instrumentCode = false
42+ // ./gradlew verifyPlugin checks the plugin against the oldest IDE it supports and the ones since, with JetBrains'
43+ // Plugin Verifier (the same check the Marketplace runs).
44+ pluginVerification {
45+ ides {
46+ create(IntelliJPlatformType.IntellijIdeaCommunity, "2024.3.6")
47+ create(IntelliJPlatformType.IntellijIdeaCommunity, "2025.2.4")
48+ create(IntelliJPlatformType.IntellijIdeaUltimate, providers.gradleProperty("platformVersion"))
49+ }
50+ }
51+ pluginConfiguration {
52+ version = providers.gradleProperty("pluginVersion")
53+ ideaVersion {
54+ sinceBuild = providers.gradleProperty("sinceBuild")
55+ untilBuild = provider { null }
56+ }
57+ }
58+}
59+
60+// A fingerprint of src/main inside the plugin, and its version for the plugin's User-Agent. Diffinery's server build (apps/diffinery/scripts/build-docs.mjs)
61+// recomputes it from the sources and refuses a dist/ zip that was built from older ones.
62+val sourcesFingerprint = tasks.register("sourcesFingerprint") {
63+ val src = layout.projectDirectory.dir("src/main")
64+ val out = layout.buildDirectory.dir("generated/fingerprint")
65+ val pluginVersion = version.toString()
66+ inputs.dir(src)
67+ inputs.property("version", pluginVersion)
68+ outputs.dir(out)
69+ doLast {
70+ fun sha256(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
71+ val root = src.asFile
72+ val listing = root.walkTopDown().filter { it.isFile }.map { it.relativeTo(root).invariantSeparatorsPath }.sorted()
73+ .joinToString("") { "$it ${sha256(File(root, it).readBytes())}\n" }
74+ val file = out.get().file("META-INF/diffinery-sources.sha256").asFile
75+ file.parentFile.mkdirs()
76+ file.writeText(sha256(listing.toByteArray()) + "\n")
77+ out.get().file("META-INF/diffinery-version.txt").asFile.writeText(pluginVersion + "\n")
78+ }
79+}
80+sourceSets.main { resources.srcDir(sourcesFingerprint) }
81+
82+// dist/ holds the zip diffinery.com serves (checked in, since the server's build has no JDK): ./gradlew dist
83+tasks.register<Copy>("dist") {
84+ from(tasks.named("buildPlugin"))
85+ into(layout.projectDirectory.dir("dist"))
86+ doFirst { layout.projectDirectory.dir("dist").asFile.listFiles()?.filter { it.name.endsWith(".zip") }?.forEach { it.delete() } }
87+}
88+
89+// The same sources make the same zip, so dist/ only changes when the plugin does.
90+tasks.withType<AbstractArchiveTask>().configureEach {
91+ isPreserveFileTimestamps = false
92+ isReproducibleFileOrder = true
93+}
addedapps/jetbrains/dist/diffinery-jetbrains-0.1.0.zip+0−0View file
Binary file not shown.
addedapps/jetbrains/gradle.properties+6−0View file
@@ -1,0 +1,6 @@
1+pluginVersion=0.1.0
2+# The IDE the plugin is built against. -PideaHome=/path/to/idea uses one already on disk instead of downloading it.
3+platformVersion=2026.2.3
4+sinceBuild=243
5+org.gradle.jvmargs=-Xmx2g
6+org.gradle.configuration-cache=false
addedapps/jetbrains/gradle/wrapper/gradle-wrapper.jar+0−0View file
Binary file not shown.
addedapps/jetbrains/gradle/wrapper/gradle-wrapper.properties+7−0View file
@@ -1,0 +1,7 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
4+networkTimeout=10000
5+validateDistributionUrl=true
6+zipStoreBase=GRADLE_USER_HOME
7+zipStorePath=wrapper/dists
addedapps/jetbrains/gradlew+251−0View file
@@ -1,0 +1,251 @@
1+#!/bin/sh
2+
3+#
4+# Copyright © 2015-2021 the original authors.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# https://www.apache.org/licenses/LICENSE-2.0
11+#
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+#
18+# SPDX-License-Identifier: Apache-2.0
19+#
20+
21+##############################################################################
22+#
23+# Gradle start up script for POSIX generated by Gradle.
24+#
25+# Important for running:
26+#
27+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28+# noncompliant, but you have some other compliant shell such as ksh or
29+# bash, then to run this script, type that shell name before the whole
30+# command line, like:
31+#
32+# ksh Gradle
33+#
34+# Busybox and similar reduced shells will NOT work, because this script
35+# requires all of these POSIX shell features:
36+# * functions;
37+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39+# * compound commands having a testable exit status, especially «case»;
40+# * various built-in commands including «command», «set», and «ulimit».
41+#
42+# Important for patching:
43+#
44+# (2) This script targets any POSIX shell, so it avoids extensions provided
45+# by Bash, Ksh, etc; in particular arrays are avoided.
46+#
47+# The "traditional" practice of packing multiple parameters into a
48+# space-separated string is a well documented source of bugs and security
49+# problems, so this is (mostly) avoided, by progressively accumulating
50+# options in "$@", and eventually passing that to Java.
51+#
52+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54+# see the in-line comments for details.
55+#
56+# There are tweaks for specific operating systems such as AIX, CygWin,
57+# Darwin, MinGW, and NonStop.
58+#
59+# (3) This script is generated from the Groovy template
60+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61+# within the Gradle project.
62+#
63+# You can find Gradle at https://github.com/gradle/gradle/.
64+#
65+##############################################################################
66+
67+# Attempt to set APP_HOME
68+
69+# Resolve links: $0 may be a link
70+app_path=$0
71+
72+# Need this for daisy-chained symlinks.
73+while
74+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75+ [ -h "$app_path" ]
76+do
77+ ls=$( ls -ld "$app_path" )
78+ link=${ls#*' -> '}
79+ case $link in #(
80+ /*) app_path=$link ;; #(
81+ *) app_path=$APP_HOME$link ;;
82+ esac
83+done
84+
85+# This is normally unused
86+# shellcheck disable=SC2034
87+APP_BASE_NAME=${0##*/}
88+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90+
91+# Use the maximum available, or set MAX_FD != -1 to use that value.
92+MAX_FD=maximum
93+
94+warn () {
95+ echo "$*"
96+} >&2
97+
98+die () {
99+ echo
100+ echo "$*"
101+ echo
102+ exit 1
103+} >&2
104+
105+# OS specific support (must be 'true' or 'false').
106+cygwin=false
107+msys=false
108+darwin=false
109+nonstop=false
110+case "$( uname )" in #(
111+ CYGWIN* ) cygwin=true ;; #(
112+ Darwin* ) darwin=true ;; #(
113+ MSYS* | MINGW* ) msys=true ;; #(
114+ NONSTOP* ) nonstop=true ;;
115+esac
116+
117+CLASSPATH="\\\"\\\""
118+
119+
120+# Determine the Java command to use to start the JVM.
121+if [ -n "$JAVA_HOME" ] ; then
122+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123+ # IBM's JDK on AIX uses strange locations for the executables
124+ JAVACMD=$JAVA_HOME/jre/sh/java
125+ else
126+ JAVACMD=$JAVA_HOME/bin/java
127+ fi
128+ if [ ! -x "$JAVACMD" ] ; then
129+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130+
131+Please set the JAVA_HOME variable in your environment to match the
132+location of your Java installation."
133+ fi
134+else
135+ JAVACMD=java
136+ if ! command -v java >/dev/null 2>&1
137+ then
138+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
139+
140+Please set the JAVA_HOME variable in your environment to match the
141+location of your Java installation."
142+ fi
143+fi
144+
145+# Increase the maximum file descriptors if we can.
146+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
147+ case $MAX_FD in #(
148+ max*)
149+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
150+ # shellcheck disable=SC2039,SC3045
151+ MAX_FD=$( ulimit -H -n ) ||
152+ warn "Could not query maximum file descriptor limit"
153+ esac
154+ case $MAX_FD in #(
155+ '' | soft) :;; #(
156+ *)
157+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
158+ # shellcheck disable=SC2039,SC3045
159+ ulimit -n "$MAX_FD" ||
160+ warn "Could not set maximum file descriptor limit to $MAX_FD"
161+ esac
162+fi
163+
164+# Collect all arguments for the java command, stacking in reverse order:
165+# * args from the command line
166+# * the main class name
167+# * -classpath
168+# * -D...appname settings
169+# * --module-path (only if needed)
170+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
171+
172+# For Cygwin or MSYS, switch paths to Windows format before running java
173+if "$cygwin" || "$msys" ; then
174+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
175+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
176+
177+ JAVACMD=$( cygpath --unix "$JAVACMD" )
178+
179+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
180+ for arg do
181+ if
182+ case $arg in #(
183+ -*) false ;; # don't mess with options #(
184+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
185+ [ -e "$t" ] ;; #(
186+ *) false ;;
187+ esac
188+ then
189+ arg=$( cygpath --path --ignore --mixed "$arg" )
190+ fi
191+ # Roll the args list around exactly as many times as the number of
192+ # args, so each arg winds up back in the position where it started, but
193+ # possibly modified.
194+ #
195+ # NB: a `for` loop captures its iteration list before it begins, so
196+ # changing the positional parameters here affects neither the number of
197+ # iterations, nor the values presented in `arg`.
198+ shift # remove old arg
199+ set -- "$@" "$arg" # push replacement arg
200+ done
201+fi
202+
203+
204+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
205+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
206+
207+# Collect all arguments for the java command:
208+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
209+# and any embedded shellness will be escaped.
210+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
211+# treated as '${Hostname}' itself on the command line.
212+
213+set -- \
214+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
215+ -classpath "$CLASSPATH" \
216+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
217+ "$@"
218+
219+# Stop when "xargs" is not available.
220+if ! command -v xargs >/dev/null 2>&1
221+then
222+ die "xargs is not available"
223+fi
224+
225+# Use "xargs" to parse quoted args.
226+#
227+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
228+#
229+# In Bash we could simply go:
230+#
231+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
232+# set -- "${ARGS[@]}" "$@"
233+#
234+# but POSIX shell has neither arrays nor command substitution, so instead we
235+# post-process each arg (as a line of input to sed) to backslash-escape any
236+# character that might be a shell metacharacter, then use eval to reverse
237+# that process (while maintaining the separation between arguments), and wrap
238+# the whole thing up as a single "set" statement.
239+#
240+# This will of course break if any of these variables contains a newline or
241+# an unmatched quote.
242+#
243+
244+eval "set -- $(
245+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
246+ xargs -n1 |
247+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
248+ tr '\n' ' '
249+ )" '"$@"'
250+
251+exec "$JAVACMD" "$@"
addedapps/jetbrains/gradlew.bat+94−0View file
@@ -1,0 +1,94 @@
1+@rem
2+@rem Copyright 2015 the original author or authors.
3+@rem
4+@rem Licensed under the Apache License, Version 2.0 (the "License");
5+@rem you may not use this file except in compliance with the License.
6+@rem You may obtain a copy of the License at
7+@rem
8+@rem https://www.apache.org/licenses/LICENSE-2.0
9+@rem
10+@rem Unless required by applicable law or agreed to in writing, software
11+@rem distributed under the License is distributed on an "AS IS" BASIS,
12+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+@rem See the License for the specific language governing permissions and
14+@rem limitations under the License.
15+@rem
16+@rem SPDX-License-Identifier: Apache-2.0
17+@rem
18+
19+@if "%DEBUG%"=="" @echo off
20+@rem ##########################################################################
21+@rem
22+@rem Gradle startup script for Windows
23+@rem
24+@rem ##########################################################################
25+
26+@rem Set local scope for the variables with windows NT shell
27+if "%OS%"=="Windows_NT" setlocal
28+
29+set DIRNAME=%~dp0
30+if "%DIRNAME%"=="" set DIRNAME=.
31+@rem This is normally unused
32+set APP_BASE_NAME=%~n0
33+set APP_HOME=%DIRNAME%
34+
35+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
36+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37+
38+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40+
41+@rem Find java.exe
42+if defined JAVA_HOME goto findJavaFromJavaHome
43+
44+set JAVA_EXE=java.exe
45+%JAVA_EXE% -version >NUL 2>&1
46+if %ERRORLEVEL% equ 0 goto execute
47+
48+echo. 1>&2
49+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50+echo. 1>&2
51+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52+echo location of your Java installation. 1>&2
53+
54+goto fail
55+
56+:findJavaFromJavaHome
57+set JAVA_HOME=%JAVA_HOME:"=%
58+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59+
60+if exist "%JAVA_EXE%" goto execute
61+
62+echo. 1>&2
63+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64+echo. 1>&2
65+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66+echo location of your Java installation. 1>&2
67+
68+goto fail
69+
70+:execute
71+@rem Setup the command line
72+
73+set CLASSPATH=
74+
75+
76+@rem Execute Gradle
77+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78+
79+:end
80+@rem End local scope for the variables with windows NT shell
81+if %ERRORLEVEL% equ 0 goto mainEnd
82+
83+:fail
84+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85+rem the _cmd.exe /c_ return code!
86+set EXIT_CODE=%ERRORLEVEL%
87+if %EXIT_CODE% equ 0 set EXIT_CODE=1
88+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89+exit /b %EXIT_CODE%
90+
91+:mainEnd
92+if "%OS%"=="Windows_NT" endlocal
93+
94+:omega
addedapps/jetbrains/scripts/plugin.mjs+82−0View file
@@ -1,0 +1,82 @@
1+// Reads the plugin zip in apps/jetbrains/dist for Diffinery's server build (apps/diffinery/scripts/build-docs.mjs),
2+// which serves it at /-/jetbrains/. The zip is checked in because the server's build has no JDK, so this refuses one
3+// that doesn't match the sources: its version must be gradle.properties' pluginVersion, and the fingerprint of
4+// src/main built into it (see sourcesFingerprint in build.gradle.kts) must match the sources as they are now.
5+// Rebuild it with `./gradlew dist`.
6+import { createHash } from "node:crypto";
7+import { readdir, readFile } from "node:fs/promises";
8+import path from "node:path";
9+import { fileURLToPath } from "node:url";
10+import { inflateRawSync } from "node:zlib";
11+
12+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13+const sha256 = (b) => createHash("sha256").update(b).digest("hex");
14+
15+/** The entries of a zip file, by name. Enough of the format for what Gradle writes: stored or deflated, no zip64. */
16+export function unzip(buf) {
17+ let eocd = -1;
18+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--)
19+ if (buf.readUInt32LE(i) === 0x06054b50) {
20+ eocd = i;
21+ break;
22+ }
23+ if (eocd < 0) throw new Error("not a zip file");
24+ const count = buf.readUInt16LE(eocd + 10);
25+ let p = buf.readUInt32LE(eocd + 16);
26+ const entries = new Map();
27+ for (let n = 0; n < count; n++) {
28+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error("bad zip central directory");
29+ const method = buf.readUInt16LE(p + 10);
30+ const size = buf.readUInt32LE(p + 20);
31+ const nameLen = buf.readUInt16LE(p + 28);
32+ const local = buf.readUInt32LE(p + 42);
33+ const name = buf.subarray(p + 46, p + 46 + nameLen).toString("utf8");
34+ p += 46 + nameLen + buf.readUInt16LE(p + 30) + buf.readUInt16LE(p + 32);
35+ if (name.endsWith("/")) continue;
36+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
37+ const raw = buf.subarray(start, start + size);
38+ if (method === 0) entries.set(name, raw);
39+ else if (method === 8) entries.set(name, inflateRawSync(raw));
40+ else throw new Error(`${name}: zip method ${method} isn't supported`);
41+ }
42+ return entries;
43+}
44+
45+/** The same fingerprint of src/main as build.gradle.kts computes. */
46+export async function sourcesFingerprint() {
47+ const src = path.join(root, "src/main");
48+ const files = (await readdir(src, { recursive: true, withFileTypes: true })).filter((d) => d.isFile()).map((d) => path.relative(src, path.join(d.parentPath, d.name)).split(path.sep).join("/"));
49+ files.sort();
50+ let listing = "";
51+ for (const f of files) listing += `${f} ${sha256(await readFile(path.join(src, f)))}\n`;
52+ return sha256(listing);
53+}
54+
55+export async function readPlugin() {
56+ const props = Object.fromEntries(
57+ (await readFile(path.join(root, "gradle.properties"), "utf8"))
58+ .split("\n")
59+ .filter((l) => /^\w+=/.test(l))
60+ .map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1).trim()]),
61+ );
62+ const version = props.pluginVersion;
63+ const fileName = `diffinery-jetbrains-${version}.zip`;
64+ const rebuild = "rebuild it with `./gradlew dist` in apps/jetbrains (see apps/jetbrains/README.md)";
65+ const inDist = (await readdir(path.join(root, "dist")).catch(() => [])).filter((f) => f.endsWith(".zip"));
66+ if (!inDist.includes(fileName)) throw new Error(`apps/jetbrains/dist/${fileName} is missing: ${rebuild}`);
67+ if (inDist.length > 1) throw new Error(`apps/jetbrains/dist has more than one zip (${inDist.join(", ")}): keep only ${fileName}`);
68+ const zip = await readFile(path.join(root, "dist", fileName));
69+ const jarName = `diffinery-jetbrains/lib/diffinery-jetbrains-${version}.jar`;
70+ const jarBytes = unzip(zip).get(jarName);
71+ if (!jarBytes) throw new Error(`apps/jetbrains/dist/${fileName} has no ${jarName}: ${rebuild}`);
72+ const jar = unzip(jarBytes);
73+ const xml = jar.get("META-INF/plugin.xml")?.toString("utf8") ?? "";
74+ const built = jar.get("META-INF/diffinery-sources.sha256")?.toString("utf8").trim();
75+ if (built !== (await sourcesFingerprint())) throw new Error(`apps/jetbrains/dist/${fileName} was built from other sources than apps/jetbrains/src/main: ${rebuild}`);
76+ const attr = (re) => xml.match(re)?.[1];
77+ const id = attr(/<id>([^<]+)<\/id>/);
78+ const sinceBuild = attr(/since-build="([^"]+)"/);
79+ if (id !== "com.diffinery" || attr(/<version>([^<]+)<\/version>/) !== version || sinceBuild !== props.sinceBuild)
80+ throw new Error(`apps/jetbrains/dist/${fileName}: its plugin.xml doesn't match gradle.properties: ${rebuild}`);
81+ return { id, version, sinceBuild, fileName, zip, description: attr(/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>/)?.trim() ?? "" };
82+}
addedapps/jetbrains/settings.gradle.kts+9−0View file
@@ -1,0 +1,9 @@
1+rootProject.name = "diffinery-jetbrains"
2+
3+// Maven Central through JetBrains' cache first: Central rate-limits busy networks (CI runners) with 429s.
4+pluginManagement {
5+ repositories {
6+ maven("https://cache-redirector.jetbrains.com/maven-central")
7+ gradlePluginPortal()
8+ }
9+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Accounts.java+282−0View file
@@ -1,0 +1,282 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.credentialStore.CredentialAttributes;
4+import com.intellij.credentialStore.Credentials;
5+import com.intellij.ide.BrowserUtil;
6+import com.intellij.ide.passwordSafe.PasswordSafe;
7+import com.intellij.notification.Notification;
8+import com.intellij.notification.NotificationAction;
9+import com.intellij.notification.NotificationType;
10+import com.intellij.notification.Notifications;
11+import com.intellij.openapi.Disposable;
12+import com.intellij.openapi.application.ApplicationManager;
13+import com.intellij.openapi.components.PersistentStateComponent;
14+import com.intellij.openapi.components.Service;
15+import com.intellij.openapi.components.State;
16+import com.intellij.openapi.components.Storage;
17+import com.intellij.openapi.diagnostic.Logger;
18+import com.intellij.openapi.progress.ProgressIndicator;
19+import com.intellij.openapi.util.Disposer;
20+import java.io.IOException;
21+import java.util.LinkedHashMap;
22+import java.util.List;
23+import java.util.Map;
24+import java.util.concurrent.CopyOnWriteArrayList;
25+import java.util.concurrent.ExecutionException;
26+import java.util.concurrent.TimeUnit;
27+import java.util.concurrent.TimeoutException;
28+
29+/**
30+ * The signed-in Diffinery account. Who it is lives in the IDE's settings (diffinery.xml); the tokens live in the
31+ * IDE's password storage (the system keychain where there is one). Browser sign-in uses Diffinery's OAuth with PKCE
32+ * and a loopback redirect: tokens last 8 hours and are renewed here before they run out.
33+ */
34+@Service(Service.Level.APP)
35+@State(name = "Diffinery", storages = @Storage("diffinery.xml"))
36+public final class Accounts implements PersistentStateComponent<Accounts.Settings> {
37+ private static final Logger LOG = Logger.getInstance(Accounts.class);
38+ private static final long RENEW_BEFORE_MS = 10 * 60_000;
39+ private static final long SIGN_IN_TIMEOUT_MS = 10 * 60_000;
40+
41+ /** Settings and who is signed in: no secrets. */
42+ public static final class Settings {
43+ public String server = Util.DEFAULT_SERVER;
44+ public String login;
45+ /** "oauth" (browser sign-in) or "token" (a pasted access token). */
46+ public String kind;
47+ public long expiresAt;
48+ }
49+
50+ public record Account(String server, String login, boolean oauth) {}
51+
52+ private Settings settings = new Settings();
53+ private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
54+ private final Object renewing = new Object();
55+
56+ public static Accounts get() {
57+ return ApplicationManager.getApplication().getService(Accounts.class);
58+ }
59+
60+ @Override
61+ public Settings getState() {
62+ return settings;
63+ }
64+
65+ @Override
66+ public void loadState(Settings state) {
67+ settings = state;
68+ }
69+
70+ /** The server to sign in to: the setting, checked. */
71+ public String server() {
72+ return Util.normalizeServer(settings.server);
73+ }
74+
75+ /** Switches to another server (a self-hosted Diffinery), signing out of the current one. */
76+ public void setServer(String server) {
77+ String s = Util.normalizeServer(server);
78+ if (s.equals(server())) return;
79+ String old = server();
80+ Secret secret;
81+ synchronized (renewing) {
82+ secret = "oauth".equals(settings.kind) ? secret() : null;
83+ forget();
84+ settings.server = s;
85+ }
86+ if (secret != null && secret.refresh() != null) ApplicationManager.getApplication().executeOnPooledThread(() -> revoke(old, secret.refresh()));
87+ changed();
88+ }
89+
90+ public Account account() {
91+ Settings s = settings;
92+ return s.login == null ? null : new Account(server(), s.login, "oauth".equals(s.kind));
93+ }
94+
95+ /** Runs `listener` (on any thread) whenever someone signs in or out, until `parent` is disposed. */
96+ public void onChange(Runnable listener, Disposable parent) {
97+ listeners.add(listener);
98+ Disposer.register(parent, () -> listeners.remove(listener));
99+ }
100+
101+ private void changed() {
102+ for (Runnable l : listeners) l.run();
103+ }
104+
105+ // ------------------------------------------------------------------ secrets
106+
107+ private CredentialAttributes attributes(String server) {
108+ return new CredentialAttributes("Diffinery — " + server);
109+ }
110+
111+ private record Secret(String access, String refresh) {}
112+
113+ private Secret secret() {
114+ Credentials c = PasswordSafe.getInstance().get(attributes(server()));
115+ String raw = c == null ? null : c.getPasswordAsString();
116+ if (raw == null || raw.isEmpty()) return null;
117+ Map<String, Object> o = Json.object(Json.parse(raw));
118+ return new Secret(Json.string(o, "access"), Json.string(o, "refresh"));
119+ }
120+
121+ private void store(String login, String kind, String access, String refresh, long expiresAt) {
122+ String json = "{\"access\":" + Json.quote(access) + (refresh == null ? "" : ",\"refresh\":" + Json.quote(refresh)) + "}";
123+ PasswordSafe.getInstance().set(attributes(server()), new Credentials(login, json));
124+ settings.login = login;
125+ settings.kind = kind;
126+ settings.expiresAt = expiresAt;
127+ }
128+
129+ private void forget() {
130+ PasswordSafe.getInstance().set(attributes(server()), null);
131+ settings.login = null;
132+ settings.kind = null;
133+ settings.expiresAt = 0;
134+ }
135+
136+ // ------------------------------------------------------------------ tokens
137+
138+ /**
139+ * A token for the signed-in account, renewed first when it runs out within 10 minutes; null when nobody is signed
140+ * in, or the sign-in has lapsed (then the person is told to sign in again). Blocks: not on the UI thread.
141+ */
142+ public String token() {
143+ synchronized (renewing) {
144+ Settings s = settings;
145+ if (s.login == null) return null;
146+ Secret secret = secret();
147+ if (secret == null || secret.access() == null) return null;
148+ if (!"oauth".equals(s.kind) || s.expiresAt - System.currentTimeMillis() > RENEW_BEFORE_MS) return secret.access();
149+ if (secret.refresh() == null) return null;
150+ try {
151+ Map<String, String> form = new LinkedHashMap<>();
152+ form.put("grant_type", "refresh_token");
153+ form.put("refresh_token", secret.refresh());
154+ form.put("client_id", Util.clientId(server()));
155+ Map<String, Object> t = tokenRequest(form);
156+ store(s.login, "oauth", Json.string(t, "access_token"), Json.string(t, "refresh_token"), expiry(t));
157+ changed();
158+ return Json.string(t, "access_token");
159+ } catch (Api.Failure e) {
160+ // The connection was disconnected on Diffinery, or lapsed: sign in again.
161+ LOG.info("Renewing the Diffinery token failed: " + e.getMessage());
162+ forget();
163+ changed();
164+ notifySignedOut("Your Diffinery sign-in has expired or was disconnected. Sign in again to keep cloning and pushing.");
165+ return null;
166+ } catch (IOException e) {
167+ // Offline: the current token may still work for a few minutes.
168+ LOG.warn("Renewing the Diffinery token failed", e);
169+ return s.expiresAt > System.currentTimeMillis() ? secret.access() : null;
170+ }
171+ }
172+ }
173+
174+ private static long expiry(Map<String, Object> t) {
175+ return System.currentTimeMillis() + Json.number(t, "expires_in", 3600) * 1000;
176+ }
177+
178+ private Map<String, Object> tokenRequest(Map<String, String> form) throws IOException {
179+ Api.Response r = Api.send("POST", server() + "/oauth/token", null, "application/x-www-form-urlencoded", Util.form(form));
180+ Map<String, Object> o = Json.object(Json.parse(r.body()));
181+ if (r.status() / 100 != 2 || Json.string(o, "access_token") == null) {
182+ String d = Json.string(o, "error_description");
183+ throw new Api.Failure(r.status(), d != null ? d : Json.string(o, "error") != null ? Json.string(o, "error") : Api.message(r));
184+ }
185+ return o;
186+ }
187+
188+ /** An API client for the signed-in account, or null. Blocks: not on the UI thread. */
189+ public Api api() {
190+ String t = token();
191+ return t == null ? null : new Api(server(), t);
192+ }
193+
194+ // ------------------------------------------------------------------ signing in and out
195+
196+ /** Signs in through the browser: Diffinery's consent page, with the person's passkey. Blocks until it's done. */
197+ public Account signInWithBrowser(ProgressIndicator indicator) throws Exception {
198+ String server = server();
199+ String verifier = Util.random(32);
200+ String state = Util.random(16);
201+ try (Loopback loopback = new Loopback()) {
202+ String redirect = loopback.redirectUri();
203+ indicator.setText("Waiting for you to approve the connection in your browser…");
204+ BrowserUtil.browse(Util.authorizeUrl(server, redirect, Util.challenge(verifier), state));
205+ String query = null;
206+ long end = System.currentTimeMillis() + SIGN_IN_TIMEOUT_MS;
207+ while (query == null) {
208+ indicator.checkCanceled();
209+ if (System.currentTimeMillis() > end) throw new IllegalStateException("Signing in took too long. Try again.");
210+ try {
211+ query = loopback.query.get(250, TimeUnit.MILLISECONDS);
212+ } catch (TimeoutException ignored) {
213+ // keep waiting
214+ } catch (ExecutionException e) {
215+ throw new IllegalStateException(e.getCause());
216+ }
217+ }
218+ String code = Util.readCallback(query, state, server);
219+ indicator.setText("Signing in…");
220+ Map<String, String> form = new LinkedHashMap<>();
221+ form.put("grant_type", "authorization_code");
222+ form.put("code", code);
223+ form.put("redirect_uri", redirect);
224+ form.put("client_id", Util.clientId(server));
225+ form.put("code_verifier", verifier);
226+ Map<String, Object> t = tokenRequest(form);
227+ String access = Json.string(t, "access_token");
228+ String login = Json.string(new Api(server, access).user(), "login");
229+ synchronized (renewing) {
230+ revokeCurrent();
231+ store(login, "oauth", access, Json.string(t, "refresh_token"), expiry(t));
232+ }
233+ }
234+ changed();
235+ return account();
236+ }
237+
238+ /** Signs in with an access token someone pastes: for an agent account, or where a browser can't reach the IDE. */
239+ public Account signInWithToken(String token) throws IOException {
240+ String t = token.trim();
241+ String login = Json.string(new Api(server(), t).user(), "login");
242+ synchronized (renewing) {
243+ revokeCurrent();
244+ store(login, "token", t, null, 0);
245+ }
246+ changed();
247+ return account();
248+ }
249+
250+ /** Signs out here, and ends a browser sign-in's connection on Diffinery too. Blocks: not on the UI thread. */
251+ public void signOut() {
252+ synchronized (renewing) {
253+ revokeCurrent();
254+ forget();
255+ }
256+ changed();
257+ }
258+
259+ /** Ends the current browser sign-in's connection on Diffinery. A pasted token is the person's own: only forgotten. */
260+ private void revokeCurrent() {
261+ if (!"oauth".equals(settings.kind)) return;
262+ Secret s = secret();
263+ if (s != null && s.refresh() != null) revoke(server(), s.refresh());
264+ }
265+
266+ private static void revoke(String server, String refreshToken) {
267+ Map<String, String> form = new LinkedHashMap<>();
268+ form.put("token", refreshToken);
269+ form.put("client_id", Util.clientId(server));
270+ try {
271+ Api.send("POST", server + "/oauth/revoke", null, "application/x-www-form-urlencoded", Util.form(form));
272+ } catch (IOException e) {
273+ LOG.warn("Disconnecting the IDE on Diffinery failed", e);
274+ }
275+ }
276+
277+ private void notifySignedOut(String text) {
278+ Notification n = new Notification("Diffinery", "Diffinery", text, NotificationType.WARNING);
279+ n.addAction(NotificationAction.createSimpleExpiring("Sign In…", () -> SignInDialogs.signInWithBrowser(null, null)));
280+ Notifications.Bus.notify(n);
281+ }
282+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Api.java+115−0View file
@@ -1,0 +1,115 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.util.io.HttpRequests;
4+import java.io.IOException;
5+import java.net.HttpURLConnection;
6+import java.util.ArrayList;
7+import java.util.List;
8+import java.util.Map;
9+
10+/**
11+ * Diffinery's REST API (/api/v3), as the signed-in account. Goes through the IDE's HTTP stack, so its proxy and
12+ * certificate settings apply. Every call blocks: never call it on the UI thread.
13+ */
14+final class Api {
15+ /** A response other than 2xx, with the server's message. */
16+ static final class Failure extends IOException {
17+ final int status;
18+
19+ Failure(int status, String message) {
20+ super(message);
21+ this.status = status;
22+ }
23+ }
24+
25+ record Response(int status, String body, List<String> links) {}
26+
27+ private final String server;
28+ private final String token;
29+
30+ Api(String server, String token) {
31+ this.server = server;
32+ this.token = token;
33+ }
34+
35+ /** A request to Diffinery, with this plugin's user agent; `token` may be null. */
36+ static Response send(String method, String url, String token, String contentType, String body) throws IOException {
37+ var builder = method.equals("POST") ? HttpRequests.post(url, contentType) : HttpRequests.request(url);
38+ return builder
39+ .accept("application/json")
40+ .userAgent("Diffinery-JetBrains/" + Plugin.version())
41+ .connectTimeout(15_000)
42+ .readTimeout(60_000)
43+ .throwStatusCodeException(false)
44+ .isReadResponseOnError(true)
45+ .tuner(c -> {
46+ if (token != null) c.setRequestProperty("Authorization", "Bearer " + token);
47+ })
48+ .connect(r -> {
49+ if (body != null) r.write(body);
50+ HttpURLConnection c = (HttpURLConnection) r.getConnection();
51+ int status = c.getResponseCode();
52+ String text = r.readString();
53+ return new Response(status, text, c.getHeaderFields().get("Link"));
54+ });
55+ }
56+
57+ private Response call(String method, String pathOrUrl, String body) throws IOException {
58+ String url = pathOrUrl.startsWith("http") ? pathOrUrl : server + "/api/v3" + pathOrUrl;
59+ Response r = send(method, url, token, "application/json", body);
60+ if (r.status() / 100 != 2) throw new Failure(r.status(), message(r));
61+ return r;
62+ }
63+
64+ static String message(Response r) {
65+ try {
66+ String m = Json.string(Json.object(Json.parse(r.body())), "message");
67+ if (m != null && !m.isEmpty()) return m + " (" + r.status() + ")";
68+ } catch (RuntimeException ignored) {
69+ // not JSON
70+ }
71+ return "Diffinery answered " + r.status();
72+ }
73+
74+ Map<String, Object> user() throws IOException {
75+ return Json.object(Json.parse(call("GET", "/user", null).body()));
76+ }
77+
78+ /** The repositories the account can push to or owns, most recently pushed first, up to `max`. */
79+ List<Repo> myRepos(int max) throws IOException {
80+ List<Repo> out = new ArrayList<>();
81+ String next = "/user/repos?per_page=100&sort=pushed";
82+ while (next != null && out.size() < max) {
83+ Response r = call("GET", next, null);
84+ for (Object o : Json.array(Json.parse(r.body()))) out.add(Repo.from(o));
85+ next = Util.nextLink(r.links());
86+ }
87+ return out;
88+ }
89+
90+ List<Repo> search(String query) throws IOException {
91+ Response r = call("GET", "/search/repositories?per_page=30&q=" + Util.enc(query), null);
92+ List<Repo> out = new ArrayList<>();
93+ for (Object o : Json.array(Json.object(Json.parse(r.body())).get("items"))) out.add(Repo.from(o));
94+ return out;
95+ }
96+
97+ Repo repo(String owner, String name) throws IOException {
98+ return Repo.from(Json.parse(call("GET", "/repos/" + Util.enc(owner) + "/" + Util.enc(name), null).body()));
99+ }
100+
101+ List<String> orgs() throws IOException {
102+ List<String> out = new ArrayList<>();
103+ for (Object o : Json.array(Json.parse(call("GET", "/user/orgs", null).body()))) {
104+ String login = Json.string(Json.object(o), "login");
105+ if (login != null) out.add(login);
106+ }
107+ return out;
108+ }
109+
110+ /** Creates a repository for the account (owner null) or an organization. */
111+ Repo createRepo(String owner, String name, boolean isPrivate) throws IOException {
112+ String body = "{\"name\":" + Json.quote(name) + ",\"private\":" + isPrivate + "}";
113+ return Repo.from(Json.parse(call("POST", owner == null ? "/user/repos" : "/orgs/" + Util.enc(owner) + "/repos", body).body()));
114+ }
115+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneAction.java+25−0View file
@@ -1,0 +1,25 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.openapi.actionSystem.ActionUpdateThread;
4+import com.intellij.openapi.actionSystem.AnActionEvent;
5+import com.intellij.openapi.project.DumbAwareAction;
6+import com.intellij.openapi.project.Project;
7+import com.intellij.openapi.project.ProjectManager;
8+import com.intellij.openapi.vcs.ProjectLevelVcsManager;
9+import com.intellij.util.ui.cloneDialog.VcsCloneDialog;
10+import org.jetbrains.annotations.NotNull;
11+
12+/** Git → Clone from Diffinery: Get from Version Control, with Diffinery already chosen. */
13+public final class CloneAction extends DumbAwareAction {
14+ @Override
15+ public @NotNull ActionUpdateThread getActionUpdateThread() {
16+ return ActionUpdateThread.BGT;
17+ }
18+
19+ @Override
20+ public void actionPerformed(@NotNull AnActionEvent e) {
21+ Project project = e.getProject() != null ? e.getProject() : ProjectManager.getInstance().getDefaultProject();
22+ VcsCloneDialog dialog = new VcsCloneDialog.Builder(project).forExtension(CloneExtension.class);
23+ if (dialog.showAndGet()) dialog.doClone(ProjectLevelVcsManager.getInstance(project).getCompositeCheckoutListener());
24+ }
25+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+359−0View file
@@ -1,0 +1,359 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.icons.AllIcons;
4+import com.intellij.ide.impl.ProjectUtil;
5+import com.intellij.openapi.application.ApplicationManager;
6+import com.intellij.openapi.application.ModalityState;
7+import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory;
8+import com.intellij.openapi.options.ShowSettingsUtil;
9+import com.intellij.openapi.project.Project;
10+import com.intellij.openapi.ui.TextBrowseFolderListener;
11+import com.intellij.openapi.ui.TextFieldWithBrowseButton;
12+import com.intellij.openapi.ui.ValidationInfo;
13+import com.intellij.openapi.util.Disposer;
14+import com.intellij.openapi.vcs.CheckoutProvider;
15+import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtensionComponent;
16+import com.intellij.openapi.vfs.LocalFileSystem;
17+import com.intellij.openapi.vfs.VirtualFile;
18+import com.intellij.ui.CollectionListModel;
19+import com.intellij.ui.ColoredListCellRenderer;
20+import com.intellij.ui.DocumentAdapter;
21+import com.intellij.ui.ScrollPaneFactory;
22+import com.intellij.ui.SearchTextField;
23+import com.intellij.ui.SimpleTextAttributes;
24+import com.intellij.ui.components.JBLabel;
25+import com.intellij.ui.components.JBList;
26+import com.intellij.util.ui.JBUI;
27+import com.intellij.util.ui.UIUtil;
28+import git4idea.checkout.GitCheckoutProvider;
29+import git4idea.commands.Git;
30+import java.awt.BorderLayout;
31+import java.awt.CardLayout;
32+import java.awt.GridBagConstraints;
33+import java.awt.GridBagLayout;
34+import java.awt.event.KeyAdapter;
35+import java.awt.event.KeyEvent;
36+import java.io.IOException;
37+import java.nio.file.Files;
38+import java.nio.file.Path;
39+import java.nio.file.Paths;
40+import java.util.ArrayList;
41+import java.util.LinkedHashMap;
42+import java.util.List;
43+import java.util.Map;
44+import java.util.concurrent.atomic.AtomicInteger;
45+import javax.swing.JButton;
46+import javax.swing.JComponent;
47+import javax.swing.JList;
48+import javax.swing.JPanel;
49+import javax.swing.ListSelectionModel;
50+import javax.swing.SwingConstants;
51+import javax.swing.Timer;
52+import javax.swing.event.DocumentEvent;
53+import org.jetbrains.annotations.NotNull;
54+
55+/**
56+ * The Diffinery page of Get from Version Control: sign in, then pick one of your repositories (or search all you
57+ * can read, or paste owner/name or a link) and where to put it.
58+ */
59+final class CloneComponent extends VcsCloneDialogExtensionComponent {
60+ private static final String SIGNED_OUT = "signed-out";
61+ private static final String SIGNED_IN = "signed-in";
62+
63+ private final Project project;
64+ private final ModalityState modality;
65+ private final CardLayout cards = new CardLayout();
66+ private final JPanel root = new JPanel(cards);
67+
68+ private final JBLabel account = new JBLabel();
69+ private final SearchTextField search = new SearchTextField(false);
70+ private final CollectionListModel<Repo> model = new CollectionListModel<>();
71+ private final JBList<Repo> list = new JBList<>(model);
72+ private final TextFieldWithBrowseButton directory = new TextFieldWithBrowseButton();
73+ private final JBLabel serverLabel = new JBLabel();
74+ private final JButton signInButton = new JButton("Sign In with Browser…");
75+
76+ private List<Repo> mine = List.of();
77+ private List<Repo> found = List.of();
78+ private Repo looked;
79+ private boolean directoryEdited;
80+ private boolean settingDirectory;
81+ /** Bumped on every new load and every search, so a slow answer never overwrites a newer one. */
82+ private final AtomicInteger loads = new AtomicInteger();
83+ private final AtomicInteger searches = new AtomicInteger();
84+ private final Timer searchTimer;
85+
86+ CloneComponent(Project project, ModalityState modality) {
87+ this.project = project;
88+ this.modality = modality;
89+ root.add(signedOutPanel(), SIGNED_OUT);
90+ root.add(signedInPanel(), SIGNED_IN);
91+ searchTimer = new Timer(300, e -> searchServer());
92+ searchTimer.setRepeats(false);
93+ Disposer.register(this, searchTimer::stop);
94+ Accounts.get().onChange(() -> ApplicationManager.getApplication().invokeLater(this::refresh, modality), this);
95+ refresh();
96+ }
97+
98+ // ------------------------------------------------------------------ layout
99+
100+ private JComponent signedOutPanel() {
101+ JPanel p = new JPanel(new GridBagLayout());
102+ GridBagConstraints c = new GridBagConstraints();
103+ c.gridx = 0;
104+ c.anchor = GridBagConstraints.CENTER;
105+ c.insets = JBUI.insetsBottom(8);
106+ JBLabel title = new JBLabel("Sign in to Diffinery", Plugin.ICON, SwingConstants.CENTER);
107+ title.setFont(JBUI.Fonts.label().biggerOn(3).asBold());
108+ p.add(title, c);
109+ JBLabel text = new JBLabel("<html><div style='text-align:center;width:300px'>Sign in with your passkey in the browser to see your repositories. Git then uses this sign-in to pull and push.</div></html>");
110+ text.setForeground(UIUtil.getContextHelpForeground());
111+ p.add(text, c);
112+ c.insets = JBUI.insets(8, 0);
113+ signInButton.addActionListener(e -> SignInDialogs.signInWithBrowser(project, a -> refresh()));
114+ p.add(signInButton, c);
115+ c.insets = JBUI.insetsBottom(4);
116+ p.add(Plugin.link("Use an access token…", () -> SignInDialogs.signInWithToken(project, a -> refresh())), c);
117+ serverLabel.setForeground(UIUtil.getContextHelpForeground());
118+ c.insets = JBUI.insetsTop(16);
119+ p.add(serverLabel, c);
120+ c.insets = JBUI.emptyInsets();
121+ p.add(Plugin.link("Change server…", () -> ShowSettingsUtil.getInstance().showSettingsDialog(project, SettingsPage.class)), c);
122+ return p;
123+ }
124+
125+ private JComponent signedInPanel() {
126+ JPanel header = new JPanel(new BorderLayout(JBUI.scale(8), 0));
127+ header.add(account, BorderLayout.CENTER);
128+ header.add(Plugin.link("Sign out", () -> ApplicationManager.getApplication().executeOnPooledThread(() -> Accounts.get().signOut())), BorderLayout.EAST);
129+
130+ search.getTextEditor().getEmptyText().setText("Search repositories, or paste owner/name or a link");
131+ search.addDocumentListener(new DocumentAdapter() {
132+ @Override
133+ protected void textChanged(@NotNull DocumentEvent e) {
134+ found = List.of();
135+ looked = null;
136+ show();
137+ searchTimer.restart();
138+ }
139+ });
140+ search.getTextEditor().addKeyListener(new KeyAdapter() {
141+ @Override
142+ public void keyPressed(KeyEvent e) {
143+ if (e.getKeyCode() == KeyEvent.VK_DOWN && model.getSize() > 0) {
144+ list.requestFocusInWindow();
145+ if (list.getSelectedIndex() < 0) list.setSelectedIndex(0);
146+ }
147+ }
148+ });
149+
150+ list.setName("diffinery.repositories");
151+ list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
152+ list.setCellRenderer(new ColoredListCellRenderer<>() {
153+ @Override
154+ protected void customizeCellRenderer(@NotNull JList<? extends Repo> l, Repo r, int index, boolean selected, boolean focus) {
155+ setIcon(r.isPrivate() ? AllIcons.Nodes.Padlock : AllIcons.Vcs.Branch);
156+ append(r.fullName());
157+ StringBuilder tags = new StringBuilder(r.isPrivate() ? " Private" : " Public");
158+ if (r.archived()) tags.append(" · archived");
159+ if (r.fork()) tags.append(" · fork");
160+ append(tags.toString(), SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES);
161+ if (r.description() != null && !r.description().isBlank()) append(" " + r.description(), SimpleTextAttributes.GRAYED_ATTRIBUTES);
162+ }
163+ });
164+ list.addListSelectionListener(e -> {
165+ if (e.getValueIsAdjusting()) return;
166+ Repo r = list.getSelectedValue();
167+ if (r != null && !directoryEdited) setDirectory(r);
168+ getDialogStateListener().onOkActionEnabled(r != null);
169+ });
170+
171+ directory.addBrowseFolderListener(new TextBrowseFolderListener(FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle("Directory to Clone Into"), project));
172+ directory.getTextField().getDocument().addDocumentListener(new DocumentAdapter() {
173+ @Override
174+ protected void textChanged(@NotNull DocumentEvent e) {
175+ if (!settingDirectory) directoryEdited = true;
176+ }
177+ });
178+
179+ JPanel dir = new JPanel(new BorderLayout(JBUI.scale(8), 0));
180+ dir.add(new JBLabel("Directory:"), BorderLayout.WEST);
181+ dir.add(directory, BorderLayout.CENTER);
182+
183+ JPanel top = new JPanel(new BorderLayout(0, JBUI.scale(8)));
184+ top.add(header, BorderLayout.NORTH);
185+ top.add(search, BorderLayout.SOUTH);
186+
187+ JPanel p = new JPanel(new BorderLayout(0, JBUI.scale(8)));
188+ p.add(top, BorderLayout.NORTH);
189+ p.add(ScrollPaneFactory.createScrollPane(list), BorderLayout.CENTER);
190+ p.add(dir, BorderLayout.SOUTH);
191+ p.setBorder(JBUI.Borders.empty(10, 12, 0, 12));
192+ setDirectoryText(ProjectUtil.getBaseDir());
193+ return p;
194+ }
195+
196+ private void setDirectory(Repo r) {
197+ setDirectoryText(Paths.get(ProjectUtil.getBaseDir(), Util.folderName(r.name())).toString());
198+ }
199+
200+ private void setDirectoryText(String text) {
201+ settingDirectory = true;
202+ try {
203+ directory.setText(text);
204+ } finally {
205+ settingDirectory = false;
206+ }
207+ }
208+
209+ // ------------------------------------------------------------------ state
210+
211+ private void refresh() {
212+ Accounts.Account a = Accounts.get().account();
213+ getDialogStateListener().onListItemChanged();
214+ if (a == null) {
215+ serverLabel.setText("Server: " + Util.host(Accounts.get().server()));
216+ cards.show(root, SIGNED_OUT);
217+ getDialogStateListener().onOkActionEnabled(false);
218+ return;
219+ }
220+ account.setText("<html>Signed in to " + Util.host(a.server()) + " as <b>" + a.login() + "</b></html>");
221+ cards.show(root, SIGNED_IN);
222+ load();
223+ }
224+
225+ private void load() {
226+ int gen = loads.incrementAndGet();
227+ mine = List.of();
228+ model.removeAll();
229+ list.getEmptyText().setText("Loading your repositories…");
230+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
231+ List<Repo> repos;
232+ String error = null;
233+ try {
234+ Api api = Accounts.get().api();
235+ repos = api == null ? List.of() : api.myRepos(1000);
236+ } catch (IOException e) {
237+ repos = List.of();
238+ error = e.getMessage();
239+ }
240+ List<Repo> r = repos;
241+ String err = error;
242+ ApplicationManager.getApplication().invokeLater(() -> {
243+ if (gen != loads.get()) return;
244+ mine = r;
245+ list.getEmptyText().setText(err != null ? "Couldn't load your repositories: " + err : "No repositories yet. Search, or paste owner/name.");
246+ show();
247+ }, modality);
248+ });
249+ }
250+
251+ /** The list for what's typed: a pasted owner/name or link first, then your repositories that match, then search results. */
252+ private void show() {
253+ String q = search.getText().trim();
254+ Repo selected = list.getSelectedValue();
255+ Map<String, Repo> out = new LinkedHashMap<>();
256+ if (looked != null) out.put(looked.fullName().toLowerCase(), looked);
257+ for (Repo r : mine) if (q.isEmpty() || Util.matches(r, q)) out.putIfAbsent(r.fullName().toLowerCase(), r);
258+ for (Repo r : found) out.putIfAbsent(r.fullName().toLowerCase(), r);
259+ model.replaceAll(new ArrayList<>(out.values()));
260+ if (selected != null && out.containsKey(selected.fullName().toLowerCase())) list.setSelectedValue(out.get(selected.fullName().toLowerCase()), true);
261+ else if (!q.isEmpty() && model.getSize() > 0) list.setSelectedIndex(0);
262+ getDialogStateListener().onOkActionEnabled(list.getSelectedValue() != null);
263+ }
264+
265+ private void searchServer() {
266+ String q = search.getText().trim();
267+ if (q.length() < 2) return;
268+ int gen = searches.incrementAndGet();
269+ String server = Accounts.get().server();
270+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
271+ Repo direct = null;
272+ List<Repo> results = List.of();
273+ try {
274+ Api api = Accounts.get().api();
275+ if (api == null) return;
276+ String[] ref = Util.repoRef(server, q);
277+ if (ref != null) {
278+ try {
279+ direct = api.repo(ref[0], ref[1]);
280+ } catch (Api.Failure ignored) {
281+ // not found or not readable: the search below still runs
282+ }
283+ }
284+ if (!q.contains("://")) results = api.search(q);
285+ } catch (IOException ignored) {
286+ // the local matches stay
287+ }
288+ Repo d = direct;
289+ List<Repo> r = results;
290+ ApplicationManager.getApplication().invokeLater(() -> {
291+ if (gen != searches.get() || !q.equals(search.getText().trim())) return;
292+ looked = d;
293+ found = r;
294+ show();
295+ }, modality);
296+ });
297+ }
298+
299+ // ------------------------------------------------------------------ the dialog
300+
301+ @Override
302+ public @NotNull JComponent getView() {
303+ return root;
304+ }
305+
306+ @Override
307+ public JComponent getPreferredFocusedComponent() {
308+ return Accounts.get().account() == null ? signInButton : search;
309+ }
310+
311+ @Override
312+ public void onComponentSelected() {
313+ getDialogStateListener().onOkActionNameChanged("Clone");
314+ getDialogStateListener().onOkActionEnabled(Accounts.get().account() != null && list.getSelectedValue() != null);
315+ }
316+
317+ @Override
318+ public @NotNull List<ValidationInfo> doValidateAll() {
319+ List<ValidationInfo> out = new ArrayList<>();
320+ if (Accounts.get().account() == null) {
321+ out.add(new ValidationInfo("Sign in to Diffinery first", signInButton));
322+ return out;
323+ }
324+ if (list.getSelectedValue() == null) out.add(new ValidationInfo("Choose a repository", list));
325+ String d = directory.getText().trim();
326+ if (d.isEmpty()) {
327+ out.add(new ValidationInfo("Choose a directory", directory.getTextField()));
328+ } else {
329+ try {
330+ Path p = Paths.get(d);
331+ if (Files.isRegularFile(p)) out.add(new ValidationInfo("There's a file with that name", directory.getTextField()));
332+ else if (Files.isDirectory(p)) {
333+ try (var entries = Files.list(p)) {
334+ if (entries.findAny().isPresent()) out.add(new ValidationInfo("That directory isn't empty", directory.getTextField()));
335+ }
336+ }
337+ } catch (Exception e) {
338+ out.add(new ValidationInfo("That isn't a valid directory", directory.getTextField()));
339+ }
340+ }
341+ return out;
342+ }
343+
344+ @Override
345+ public void doClone(@NotNull CheckoutProvider.Listener listener) {
346+ Repo r = list.getSelectedValue();
347+ if (r == null) return;
348+ Path dest = Paths.get(directory.getText().trim()).toAbsolutePath();
349+ Path parent = dest.getParent();
350+ try {
351+ Files.createDirectories(parent);
352+ } catch (IOException e) {
353+ throw new IllegalStateException("Couldn't create " + parent + ": " + e.getMessage(), e);
354+ }
355+ VirtualFile parentFile = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(parent);
356+ if (parentFile == null) throw new IllegalStateException("Couldn't open " + parent);
357+ GitCheckoutProvider.clone(project, Git.getInstance(), listener, parentFile, r.cloneUrl(), dest.getFileName().toString(), parent.toString());
358+ }
359+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneExtension.java+35−0View file
@@ -1,0 +1,35 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.openapi.application.ModalityState;
4+import com.intellij.openapi.project.Project;
5+import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtension;
6+import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtensionComponent;
7+import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtensionStatusLine;
8+import java.util.List;
9+import javax.swing.Icon;
10+import org.jetbrains.annotations.NotNull;
11+
12+/** "Diffinery" in the Get from Version Control dialog, next to GitHub and GitLab. */
13+public final class CloneExtension implements VcsCloneDialogExtension {
14+ @Override
15+ public @NotNull String getName() {
16+ return "Diffinery";
17+ }
18+
19+ @Override
20+ public @NotNull Icon getIcon() {
21+ return Plugin.ICON;
22+ }
23+
24+ @Override
25+ public @NotNull List<VcsCloneDialogExtensionStatusLine> getAdditionalStatusLines() {
26+ Accounts.Account a = Accounts.get().account();
27+ if (a == null) return List.of(VcsCloneDialogExtensionStatusLine.Companion.greyText("No account"));
28+ return List.of(VcsCloneDialogExtensionStatusLine.Companion.greyText(a.login()));
29+ }
30+
31+ @Override
32+ public @NotNull VcsCloneDialogExtensionComponent createMainComponent(@NotNull Project project, @NotNull ModalityState modalityState) {
33+ return new CloneComponent(project, modalityState);
34+ }
35+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/GitAuth.java+58−0View file
@@ -1,0 +1,58 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.openapi.project.Project;
4+import com.intellij.util.AuthData;
5+import git4idea.remote.GitHttpAuthDataProvider;
6+import java.net.URI;
7+import java.util.Locale;
8+import org.jetbrains.annotations.NotNull;
9+import org.jetbrains.annotations.Nullable;
10+
11+/**
12+ * Answers Git's password prompts for the Diffinery server with the signed-in account, so clone, pull and push need
13+ * no password. Silent: it never asks anything itself, and Git falls back to the IDE's own prompt when nobody's signed in.
14+ */
15+public final class GitAuth implements GitHttpAuthDataProvider {
16+ @Override
17+ public boolean isSilent() {
18+ return true;
19+ }
20+
21+ @Override
22+ public @Nullable AuthData getAuthData(@NotNull Project project, @NotNull String url) {
23+ return data(url, null);
24+ }
25+
26+ @Override
27+ public @Nullable AuthData getAuthData(@NotNull Project project, @NotNull String url, @NotNull String login) {
28+ return data(url, login);
29+ }
30+
31+ private static AuthData data(String url, String login) {
32+ Accounts accounts = Accounts.get();
33+ Accounts.Account a = accounts.account();
34+ if (a == null || !sameServer(url, a.server())) return null;
35+ // Git asked for someone else (a username in the remote URL): leave that to them.
36+ if (login != null && !login.isEmpty() && !login.equalsIgnoreCase(a.login())) return null;
37+ String token = accounts.token();
38+ return token == null ? null : new AuthData(a.login(), token);
39+ }
40+
41+ static boolean sameServer(String url, String server) {
42+ try {
43+ URI u = URI.create(url);
44+ URI s = URI.create(server);
45+ return u.getHost() != null
46+ && u.getHost().equalsIgnoreCase(s.getHost())
47+ && port(u) == port(s)
48+ && (u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT)).equals(s.getScheme().toLowerCase(Locale.ROOT));
49+ } catch (IllegalArgumentException e) {
50+ return false;
51+ }
52+ }
53+
54+ private static int port(URI u) {
55+ if (u.getPort() != -1) return u.getPort();
56+ return "http".equalsIgnoreCase(u.getScheme()) ? 80 : 443;
57+ }
58+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Json.java+186−0View file
@@ -1,0 +1,186 @@
1+package com.diffinery.jetbrains;
2+
3+import java.util.ArrayList;
4+import java.util.LinkedHashMap;
5+import java.util.List;
6+import java.util.Map;
7+
8+/**
9+ * A small JSON reader for Diffinery's API responses: objects become maps, arrays lists, numbers doubles. The
10+ * platform's own JSON libraries aren't part of its stable plugin API, and this needs only to read.
11+ */
12+final class Json {
13+ private final String s;
14+ private int i;
15+
16+ private Json(String s) {
17+ this.s = s;
18+ }
19+
20+ static Object parse(String text) {
21+ Json p = new Json(text);
22+ p.ws();
23+ Object v = p.value();
24+ p.ws();
25+ if (p.i != p.s.length()) throw p.error("unexpected text after the value");
26+ return v;
27+ }
28+
29+ @SuppressWarnings("unchecked")
30+ static Map<String, Object> object(Object v) {
31+ return v instanceof Map ? (Map<String, Object>) v : Map.of();
32+ }
33+
34+ @SuppressWarnings("unchecked")
35+ static List<Object> array(Object v) {
36+ return v instanceof List ? (List<Object>) v : List.of();
37+ }
38+
39+ static String string(Map<String, Object> o, String key) {
40+ return o.get(key) instanceof String v ? v : null;
41+ }
42+
43+ static boolean bool(Map<String, Object> o, String key) {
44+ return Boolean.TRUE.equals(o.get(key));
45+ }
46+
47+ static long number(Map<String, Object> o, String key, long fallback) {
48+ return o.get(key) instanceof Double d ? d.longValue() : fallback;
49+ }
50+
51+ /** A JSON string literal, for the little JSON the plugin writes. */
52+ static String quote(String v) {
53+ StringBuilder b = new StringBuilder("\"");
54+ for (char c : v.toCharArray()) {
55+ switch (c) {
56+ case '"' -> b.append("\\\"");
57+ case '\\' -> b.append("\\\\");
58+ case '\n' -> b.append("\\n");
59+ case '\r' -> b.append("\\r");
60+ case '\t' -> b.append("\\t");
61+ default -> {
62+ if (c < 0x20) b.append(String.format("\\u%04x", (int) c));
63+ else b.append(c);
64+ }
65+ }
66+ }
67+ return b.append('"').toString();
68+ }
69+
70+ private IllegalArgumentException error(String what) {
71+ return new IllegalArgumentException("Invalid JSON at " + i + ": " + what);
72+ }
73+
74+ private void ws() {
75+ while (i < s.length() && " \t\r\n".indexOf(s.charAt(i)) >= 0) i++;
76+ }
77+
78+ private Object value() {
79+ if (i >= s.length()) throw error("unexpected end");
80+ char c = s.charAt(i);
81+ return switch (c) {
82+ case '{' -> obj();
83+ case '[' -> arr();
84+ case '"' -> str();
85+ case 't' -> literal("true", Boolean.TRUE);
86+ case 'f' -> literal("false", Boolean.FALSE);
87+ case 'n' -> literal("null", null);
88+ default -> {
89+ if (c == '-' || (c >= '0' && c <= '9')) yield num();
90+ throw error("unexpected '" + c + "'");
91+ }
92+ };
93+ }
94+
95+ private Object literal(String word, Object v) {
96+ if (!s.startsWith(word, i)) throw error("expected " + word);
97+ i += word.length();
98+ return v;
99+ }
100+
101+ private Map<String, Object> obj() {
102+ Map<String, Object> m = new LinkedHashMap<>();
103+ i++;
104+ ws();
105+ if (peek('}')) return m;
106+ while (true) {
107+ ws();
108+ if (i >= s.length() || s.charAt(i) != '"') throw error("expected a key");
109+ String k = str();
110+ ws();
111+ expect(':');
112+ ws();
113+ m.put(k, value());
114+ ws();
115+ if (peek('}')) return m;
116+ expect(',');
117+ }
118+ }
119+
120+ private List<Object> arr() {
121+ List<Object> l = new ArrayList<>();
122+ i++;
123+ ws();
124+ if (peek(']')) return l;
125+ while (true) {
126+ ws();
127+ l.add(value());
128+ ws();
129+ if (peek(']')) return l;
130+ expect(',');
131+ }
132+ }
133+
134+ private boolean peek(char c) {
135+ if (i < s.length() && s.charAt(i) == c) {
136+ i++;
137+ return true;
138+ }
139+ return false;
140+ }
141+
142+ private void expect(char c) {
143+ if (!peek(c)) throw error("expected '" + c + "'");
144+ }
145+
146+ private String str() {
147+ i++;
148+ StringBuilder b = new StringBuilder();
149+ while (true) {
150+ if (i >= s.length()) throw error("unterminated string");
151+ char c = s.charAt(i++);
152+ if (c == '"') return b.toString();
153+ if (c != '\\') {
154+ b.append(c);
155+ continue;
156+ }
157+ if (i >= s.length()) throw error("unterminated escape");
158+ char e = s.charAt(i++);
159+ switch (e) {
160+ case '"', '\\', '/' -> b.append(e);
161+ case 'b' -> b.append('\b');
162+ case 'f' -> b.append('\f');
163+ case 'n' -> b.append('\n');
164+ case 'r' -> b.append('\r');
165+ case 't' -> b.append('\t');
166+ case 'u' -> {
167+ if (i + 4 > s.length()) throw error("short \\u escape");
168+ b.append((char) Integer.parseInt(s.substring(i, i + 4), 16));
169+ i += 4;
170+ }
171+ default -> throw error("bad escape \\" + e);
172+ }
173+ }
174+ }
175+
176+ private Double num() {
177+ int start = i;
178+ if (s.charAt(i) == '-') i++;
179+ while (i < s.length() && "0123456789.eE+-".indexOf(s.charAt(i)) >= 0) i++;
180+ try {
181+ return Double.valueOf(s.substring(start, i));
182+ } catch (NumberFormatException e) {
183+ throw error("bad number");
184+ }
185+ }
186+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Loopback.java+54−0View file
@@ -1,0 +1,54 @@
1+package com.diffinery.jetbrains;
2+
3+import com.sun.net.httpserver.HttpServer;
4+import java.io.IOException;
5+import java.io.OutputStream;
6+import java.net.InetAddress;
7+import java.net.InetSocketAddress;
8+import java.nio.charset.StandardCharsets;
9+import java.util.concurrent.CompletableFuture;
10+
11+/**
12+ * A one-time listener on 127.0.0.1 for the browser's answer after Diffinery's consent page (RFC 8252): the browser
13+ * comes back to the IDE without asking to open an app.
14+ */
15+final class Loopback implements AutoCloseable {
16+ private static final String PAGE =
17+ """
18+ <!doctype html><meta charset="utf-8"><title>Signed in to Diffinery</title>
19+ <style>body{font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;color:#1f2328}
20+ main{max-width:28rem;text-align:center}h1{font-size:1.4rem}@media(prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}}</style>
21+ <main><h1>%s</h1><p>%s</p></main>""";
22+
23+ final CompletableFuture<String> query = new CompletableFuture<>();
24+ private final HttpServer server;
25+
26+ Loopback() throws IOException {
27+ server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
28+ server.createContext("/callback", ex -> {
29+ String q = ex.getRequestURI().getRawQuery();
30+ boolean ok = q != null && Util.query(q).containsKey("code");
31+ String html = ok
32+ ? PAGE.formatted("You're signed in to Diffinery", "You can close this tab and go back to your IDE.")
33+ : PAGE.formatted("Sign-in didn't finish", "Go back to your IDE to see why, and try again.");
34+ byte[] b = html.getBytes(StandardCharsets.UTF_8);
35+ ex.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
36+ ex.getResponseHeaders().set("Cache-Control", "no-store");
37+ ex.sendResponseHeaders(200, b.length);
38+ try (OutputStream o = ex.getResponseBody()) {
39+ o.write(b);
40+ }
41+ query.complete(q == null ? "" : q);
42+ });
43+ server.start();
44+ }
45+
46+ String redirectUri() {
47+ return "http://127.0.0.1:" + server.getAddress().getPort() + "/callback";
48+ }
49+
50+ @Override
51+ public void close() {
52+ server.stop(0);
53+ }
54+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Plugin.java+26−0View file
@@ -1,0 +1,26 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.openapi.util.IconLoader;
4+import com.intellij.ui.components.ActionLink;
5+import java.awt.event.ActionListener;
6+import javax.swing.Icon;
7+
8+final class Plugin {
9+ static final String ID = "com.diffinery";
10+ static final Icon ICON = IconLoader.getIcon("/icons/diffinery.svg", Plugin.class);
11+
12+ private Plugin() {}
13+
14+ /** A link that runs `action` when clicked. */
15+ static ActionLink link(String text, Runnable action) {
16+ return new ActionLink(text, (ActionListener) e -> action.run());
17+ }
18+
19+ static String version() {
20+ try (var in = Plugin.class.getResourceAsStream("/META-INF/diffinery-version.txt")) {
21+ return in == null ? "dev" : new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).trim();
22+ } catch (java.io.IOException e) {
23+ return "dev";
24+ }
25+ }
26+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Repo.java+29−0View file
@@ -1,0 +1,29 @@
1+package com.diffinery.jetbrains;
2+
3+import java.util.Map;
4+
5+/** A repository as Diffinery's REST API describes it. */
6+record Repo(String fullName, String description, boolean isPrivate, boolean archived, boolean fork, String cloneUrl, String htmlUrl, String defaultBranch) {
7+ static Repo from(Object json) {
8+ Map<String, Object> o = Json.object(json);
9+ return new Repo(
10+ Json.string(o, "full_name"),
11+ Json.string(o, "description"),
12+ Json.bool(o, "private"),
13+ Json.bool(o, "archived"),
14+ Json.bool(o, "fork"),
15+ Json.string(o, "clone_url"),
16+ Json.string(o, "html_url"),
17+ Json.string(o, "default_branch"));
18+ }
19+
20+ String name() {
21+ int slash = fullName.indexOf('/');
22+ return slash < 0 ? fullName : fullName.substring(slash + 1);
23+ }
24+
25+ String owner() {
26+ int slash = fullName.indexOf('/');
27+ return slash < 0 ? "" : fullName.substring(0, slash);
28+ }
29+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+105−0View file
@@ -1,0 +1,105 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.openapi.Disposable;
4+import com.intellij.openapi.application.ApplicationManager;
5+import com.intellij.openapi.application.ModalityState;
6+import com.intellij.openapi.options.Configurable;
7+import com.intellij.openapi.options.ConfigurationException;
8+import com.intellij.openapi.util.Disposer;
9+import com.intellij.ui.components.ActionLink;
10+import com.intellij.ui.components.JBLabel;
11+import com.intellij.ui.components.JBTextField;
12+import com.intellij.util.ui.FormBuilder;
13+import com.intellij.util.ui.JBUI;
14+import com.intellij.util.ui.UIUtil;
15+import java.awt.FlowLayout;
16+import javax.swing.JButton;
17+import javax.swing.JComponent;
18+import javax.swing.JPanel;
19+import org.jetbrains.annotations.Nls;
20+
21+/** Settings → Version Control → Diffinery: the account, and the server for a self-hosted Diffinery. */
22+public final class SettingsPage implements Configurable {
23+ private JBTextField server;
24+ private JBLabel account;
25+ private JButton signIn;
26+ private ActionLink useToken;
27+ private ActionLink signOut;
28+ private Disposable disposable;
29+
30+ @Override
31+ public @Nls String getDisplayName() {
32+ return "Diffinery";
33+ }
34+
35+ @Override
36+ public JComponent createComponent() {
37+ disposable = Disposer.newDisposable();
38+ server = new JBTextField();
39+ account = new JBLabel();
40+ signIn = new JButton("Sign In with Browser…");
41+ signIn.addActionListener(e -> SignInDialogs.signInWithBrowser(null, a -> update()));
42+ useToken = Plugin.link("Use an access token…", () -> SignInDialogs.signInWithToken(null, a -> update()));
43+ signOut = Plugin.link("Sign out", () -> ApplicationManager.getApplication().executeOnPooledThread(() -> Accounts.get().signOut()));
44+ Accounts.get().onChange(() -> ApplicationManager.getApplication().invokeLater(this::update, ModalityState.any()), disposable);
45+
46+ JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT, JBUI.scale(8), 0));
47+ buttons.add(signIn);
48+ buttons.add(useToken);
49+ buttons.add(signOut);
50+ JBLabel serverHelp = new JBLabel("Change it only for a self-hosted Diffinery. Changing it signs you out.");
51+ serverHelp.setForeground(UIUtil.getContextHelpForeground());
52+ JBLabel gitHelp = new JBLabel("<html>When you're signed in, Git uses this account for the server: clone, pull and push need no password.</html>");
53+ gitHelp.setForeground(UIUtil.getContextHelpForeground());
54+
55+ reset();
56+ return FormBuilder.createFormBuilder()
57+ .addLabeledComponent("Account:", account)
58+ .addComponentToRightColumn(buttons)
59+ .addComponentToRightColumn(gitHelp)
60+ .addSeparator()
61+ .addLabeledComponent("Server:", server)
62+ .addComponentToRightColumn(serverHelp)
63+ .addComponentFillVertically(new JPanel(), 0)
64+ .getPanel();
65+ }
66+
67+ private void update() {
68+ if (account == null) return;
69+ Accounts.Account a = Accounts.get().account();
70+ account.setText(a == null ? "Not signed in" : "<html><b>" + a.login() + "</b> on " + Util.host(a.server()) + (a.oauth() ? "" : " (access token)") + "</html>");
71+ signIn.setVisible(a == null);
72+ useToken.setVisible(a == null);
73+ signOut.setVisible(a != null);
74+ }
75+
76+ @Override
77+ public boolean isModified() {
78+ return server != null && !server.getText().trim().equals(Accounts.get().server());
79+ }
80+
81+ @Override
82+ public void apply() throws ConfigurationException {
83+ try {
84+ Accounts.get().setServer(server.getText());
85+ } catch (IllegalArgumentException e) {
86+ throw new ConfigurationException(e.getMessage());
87+ }
88+ reset();
89+ }
90+
91+ @Override
92+ public void reset() {
93+ if (server == null) return;
94+ server.setText(Accounts.get().server());
95+ update();
96+ }
97+
98+ @Override
99+ public void disposeUIResources() {
100+ if (disposable != null) Disposer.dispose(disposable);
101+ disposable = null;
102+ server = null;
103+ account = null;
104+ }
105+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/ShareAction.java+197−0View file
@@ -1,0 +1,197 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.ide.BrowserUtil;
4+import com.intellij.notification.Notification;
5+import com.intellij.notification.NotificationAction;
6+import com.intellij.notification.NotificationType;
7+import com.intellij.notification.Notifications;
8+import com.intellij.openapi.actionSystem.ActionUpdateThread;
9+import com.intellij.openapi.actionSystem.AnActionEvent;
10+import com.intellij.openapi.progress.ProgressIndicator;
11+import com.intellij.openapi.progress.ProgressManager;
12+import com.intellij.openapi.progress.Task;
13+import com.intellij.openapi.project.DumbAwareAction;
14+import com.intellij.openapi.project.Project;
15+import com.intellij.openapi.ui.ComboBox;
16+import com.intellij.openapi.ui.DialogWrapper;
17+import com.intellij.openapi.ui.Messages;
18+import com.intellij.openapi.ui.ValidationInfo;
19+import com.intellij.ui.components.JBCheckBox;
20+import com.intellij.ui.components.JBTextField;
21+import com.intellij.util.ui.FormBuilder;
22+import git4idea.commands.Git;
23+import git4idea.commands.GitCommand;
24+import git4idea.commands.GitCommandResult;
25+import git4idea.commands.GitLineHandler;
26+import git4idea.repo.GitRemote;
27+import git4idea.repo.GitRepository;
28+import git4idea.repo.GitRepositoryManager;
29+import java.util.ArrayList;
30+import java.util.List;
31+import java.util.regex.Pattern;
32+import javax.swing.JComponent;
33+import org.jetbrains.annotations.NotNull;
34+import org.jetbrains.annotations.Nullable;
35+
36+/** Git → Share Project on Diffinery: creates a Diffinery repository for the project's Git repository and pushes it. */
37+public final class ShareAction extends DumbAwareAction {
38+ private static final Pattern NAME = Pattern.compile("[A-Za-z0-9._-]{1,100}");
39+
40+ @Override
41+ public @NotNull ActionUpdateThread getActionUpdateThread() {
42+ return ActionUpdateThread.BGT;
43+ }
44+
45+ @Override
46+ public void update(@NotNull AnActionEvent e) {
47+ Project p = e.getProject();
48+ e.getPresentation().setEnabledAndVisible(p != null && !p.isDefault());
49+ }
50+
51+ @Override
52+ public void actionPerformed(@NotNull AnActionEvent e) {
53+ Project project = e.getProject();
54+ if (project == null) return;
55+ List<GitRepository> repos = GitRepositoryManager.getInstance(project).getRepositories();
56+ if (repos.isEmpty()) {
57+ Messages.showInfoMessage(project, "This project isn't a Git repository yet. Choose VCS → Create Git Repository, commit your files, then share it.", "Share Project on Diffinery");
58+ return;
59+ }
60+ GitRepository repo = repos.get(0);
61+ if (repo.getCurrentRevision() == null || repo.getCurrentBranchName() == null) {
62+ Messages.showInfoMessage(project, "Commit your files on a branch first, then share the project.", "Share Project on Diffinery");
63+ return;
64+ }
65+ if (Accounts.get().account() == null) SignInDialogs.signInWithBrowser(project, a -> chooseAndShare(project, repo));
66+ else chooseAndShare(project, repo);
67+ }
68+
69+ private static void chooseAndShare(Project project, GitRepository repo) {
70+ ProgressManager.getInstance().run(new Task.Modal(project, "Share Project on Diffinery", true) {
71+ private Accounts.Account account;
72+ private List<String> owners = List.of();
73+ private String error;
74+
75+ @Override
76+ public void run(@NotNull ProgressIndicator indicator) {
77+ account = Accounts.get().account();
78+ try {
79+ Api api = Accounts.get().api();
80+ if (api == null) {
81+ error = "Sign in to Diffinery first.";
82+ return;
83+ }
84+ List<String> o = new ArrayList<>();
85+ o.add(account.login());
86+ o.addAll(api.orgs());
87+ owners = o;
88+ } catch (Exception ex) {
89+ error = ex.getMessage();
90+ }
91+ }
92+
93+ @Override
94+ public void onSuccess() {
95+ if (error != null) {
96+ Messages.showErrorDialog(project, error, "Share Project on Diffinery");
97+ return;
98+ }
99+ ShareDialog d = new ShareDialog(project, owners, Util.folderName(repo.getRoot().getName()));
100+ if (!d.showAndGet()) return;
101+ String owner = d.owner();
102+ share(project, repo, owner.equals(account.login()) ? null : owner, d.name(), d.isPrivate());
103+ }
104+ });
105+ }
106+
107+ private static void share(Project project, GitRepository repo, @Nullable String org, String name, boolean isPrivate) {
108+ String branch = repo.getCurrentBranchName();
109+ ProgressManager.getInstance().run(new Task.Backgroundable(project, "Sharing on Diffinery", true) {
110+ private Repo created;
111+
112+ @Override
113+ public void run(@NotNull ProgressIndicator indicator) {
114+ Api api = Accounts.get().api();
115+ if (api == null) throw new IllegalStateException("Sign in to Diffinery first.");
116+ try {
117+ indicator.setText("Creating the repository…");
118+ created = api.createRepo(org, name, isPrivate);
119+ } catch (Exception ex) {
120+ throw new IllegalStateException("Diffinery couldn't create the repository: " + ex.getMessage(), ex);
121+ }
122+ boolean hasOrigin = repo.getRemotes().stream().map(GitRemote::getName).anyMatch("origin"::equals);
123+ String remote = hasOrigin ? "diffinery" : "origin";
124+ GitCommandResult add = Git.getInstance().addRemote(repo, remote, created.cloneUrl());
125+ if (!add.success()) throw new IllegalStateException("Adding the remote failed: " + add.getErrorOutputAsJoinedString());
126+ repo.update();
127+ indicator.setText("Pushing " + branch + "…");
128+ GitLineHandler push = new GitLineHandler(project, repo.getRoot(), GitCommand.PUSH);
129+ push.setUrl(created.cloneUrl());
130+ push.addParameters("--set-upstream", remote, "refs/heads/" + branch + ":refs/heads/" + branch);
131+ GitCommandResult pushed = Git.getInstance().runCommand(push);
132+ repo.update();
133+ if (!pushed.success()) throw new IllegalStateException("Created " + created.fullName() + ", but pushing failed: " + pushed.getErrorOutputAsJoinedString());
134+ }
135+
136+ @Override
137+ public void onSuccess() {
138+ Notification n = new Notification("Diffinery", "Shared on Diffinery", "Pushed " + branch + " to " + created.fullName() + ".", NotificationType.INFORMATION);
139+ n.addAction(NotificationAction.createSimpleExpiring("Open on Diffinery", () -> BrowserUtil.browse(created.htmlUrl())));
140+ Notifications.Bus.notify(n, project);
141+ }
142+
143+ @Override
144+ public void onThrowable(@NotNull Throwable error) {
145+ Notifications.Bus.notify(new Notification("Diffinery", "Sharing on Diffinery failed", String.valueOf(error.getMessage()), NotificationType.ERROR), project);
146+ }
147+ });
148+ }
149+
150+ private static final class ShareDialog extends DialogWrapper {
151+ private final ComboBox<String> owner;
152+ private final JBTextField name;
153+ private final JBCheckBox isPrivate = new JBCheckBox("Private: only people and teams you give access can see it", true);
154+
155+ ShareDialog(Project project, List<String> owners, String defaultName) {
156+ super(project);
157+ owner = new ComboBox<>(owners.toArray(String[]::new));
158+ name = new JBTextField(defaultName);
159+ setTitle("Share Project on Diffinery");
160+ setOKButtonText("Share");
161+ init();
162+ }
163+
164+ @Override
165+ protected @Nullable JComponent createCenterPanel() {
166+ return FormBuilder.createFormBuilder()
167+ .addLabeledComponent("Owner:", owner)
168+ .addLabeledComponent("Repository name:", name)
169+ .addComponent(isPrivate)
170+ .getPanel();
171+ }
172+
173+ @Override
174+ public @Nullable JComponent getPreferredFocusedComponent() {
175+ return name;
176+ }
177+
178+ @Override
179+ protected @Nullable ValidationInfo doValidate() {
180+ String n = name.getText().trim();
181+ if (!NAME.matcher(n).matches() || n.equals(".") || n.equals("..")) return new ValidationInfo("Use letters, digits, '.', '-' or '_'", name);
182+ return null;
183+ }
184+
185+ String owner() {
186+ return (String) owner.getSelectedItem();
187+ }
188+
189+ String name() {
190+ return name.getText().trim();
191+ }
192+
193+ boolean isPrivate() {
194+ return isPrivate.isSelected();
195+ }
196+ }
197+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+68−0View file
@@ -1,0 +1,68 @@
1+package com.diffinery.jetbrains;
2+
3+import com.intellij.openapi.application.ApplicationManager;
4+import com.intellij.openapi.application.ModalityState;
5+import com.intellij.openapi.progress.ProgressIndicator;
6+import com.intellij.openapi.progress.ProgressManager;
7+import com.intellij.openapi.progress.Task;
8+import com.intellij.openapi.project.Project;
9+import com.intellij.openapi.ui.Messages;
10+import java.util.function.Consumer;
11+import org.jetbrains.annotations.NotNull;
12+import org.jetbrains.annotations.Nullable;
13+
14+/** Signing in from anywhere in the IDE: with a progress dialog that can be cancelled, and errors shown as sentences. */
15+final class SignInDialogs {
16+ private SignInDialogs() {}
17+
18+ /** Opens the browser to sign in; `done` runs on the UI thread with the account once signed in. */
19+ static void signInWithBrowser(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) {
20+ ProgressManager.getInstance().run(new Task.Modal(project, "Sign In to Diffinery", true) {
21+ private Accounts.Account account;
22+
23+ @Override
24+ public void run(@NotNull ProgressIndicator indicator) {
25+ indicator.setIndeterminate(true);
26+ try {
27+ account = Accounts.get().signInWithBrowser(indicator);
28+ } catch (com.intellij.openapi.progress.ProcessCanceledException e) {
29+ throw e;
30+ } catch (Exception e) {
31+ String m = e.getMessage() == null ? e.toString() : e.getMessage();
32+ ApplicationManager.getApplication().invokeLater(() -> Messages.showErrorDialog(project, "Signing in to Diffinery failed: " + m, "Diffinery"), ModalityState.any());
33+ }
34+ }
35+
36+ @Override
37+ public void onSuccess() {
38+ if (account != null && done != null) done.accept(account);
39+ }
40+ });
41+ }
42+
43+ /** Asks for an access token and signs in with it; `done` runs on the UI thread with the account. */
44+ static void signInWithToken(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) {
45+ String server = Accounts.get().server();
46+ String token = Messages.showPasswordDialog(project, "Paste an access token from " + server + "/settings/tokens. Write access lets you push.", "Sign In to Diffinery with a Token", null);
47+ if (token == null || token.isBlank()) return;
48+ ProgressManager.getInstance().run(new Task.Modal(project, "Checking the Token with Diffinery", true) {
49+ private Accounts.Account account;
50+
51+ @Override
52+ public void run(@NotNull ProgressIndicator indicator) {
53+ indicator.setIndeterminate(true);
54+ try {
55+ account = Accounts.get().signInWithToken(token);
56+ } catch (Exception e) {
57+ String m = e instanceof Api.Failure f && f.status == 401 ? "Diffinery didn't accept that token." : e.getMessage();
58+ ApplicationManager.getApplication().invokeLater(() -> Messages.showErrorDialog(project, "Signing in to Diffinery failed: " + m, "Diffinery"), ModalityState.any());
59+ }
60+ }
61+
62+ @Override
63+ public void onSuccess() {
64+ if (account != null && done != null) done.accept(account);
65+ }
66+ });
67+ }
68+}
addedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Util.java+183−0View file
@@ -1,0 +1,183 @@
1+package com.diffinery.jetbrains;
2+
3+import java.net.URI;
4+import java.net.URLDecoder;
5+import java.net.URLEncoder;
6+import java.nio.charset.StandardCharsets;
7+import java.security.MessageDigest;
8+import java.security.NoSuchAlgorithmException;
9+import java.security.SecureRandom;
10+import java.util.Base64;
11+import java.util.LinkedHashMap;
12+import java.util.List;
13+import java.util.Locale;
14+import java.util.Map;
15+import java.util.regex.Matcher;
16+import java.util.regex.Pattern;
17+
18+/** Helpers without the IDE's classes, so the unit tests can run them on their own. */
19+final class Util {
20+ static final String DEFAULT_SERVER = "https://diffinery.com";
21+ private static final SecureRandom RANDOM = new SecureRandom();
22+ private static final Pattern NAME = Pattern.compile("[A-Za-z0-9._-]+");
23+ private static final Pattern SCHEME = Pattern.compile("^[a-z][a-z0-9+.-]*://", Pattern.CASE_INSENSITIVE);
24+ private static final Pattern LOOPBACK = Pattern.compile("^(localhost|127(\\.\\d{1,3}){3}|\\[::1])$");
25+
26+ private Util() {}
27+
28+ /** The server's base URL from the setting: https (or http on this computer), no trailing slash. */
29+ static String normalizeServer(String raw) {
30+ String s = raw == null || raw.isBlank() ? DEFAULT_SERVER : raw.trim();
31+ URI u;
32+ try {
33+ u = new URI(s.contains("://") ? s : "https://" + s);
34+ } catch (Exception e) {
35+ throw new IllegalArgumentException("The Diffinery server URL \"" + s + "\" isn't a valid URL.");
36+ }
37+ String scheme = u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT);
38+ String host = u.getHost();
39+ if (host == null) throw new IllegalArgumentException("The Diffinery server URL \"" + s + "\" has no host.");
40+ if (!scheme.equals("https") && !(scheme.equals("http") && LOOPBACK.matcher(host).matches()))
41+ throw new IllegalArgumentException("The Diffinery server URL must use https (it is " + s + ").");
42+ String path = u.getRawPath() == null ? "" : u.getRawPath().replaceAll("/+$", "");
43+ return scheme + "://" + u.getRawAuthority() + path;
44+ }
45+
46+ /** The OAuth client_id of this plugin on a server: the URL of the metadata document the server publishes about it. */
47+ static String clientId(String server) {
48+ return server + "/oauth/clients/jetbrains.json";
49+ }
50+
51+ static String host(String server) {
52+ return URI.create(server).getRawAuthority().toLowerCase(Locale.ROOT);
53+ }
54+
55+ static String b64url(byte[] b) {
56+ return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
57+ }
58+
59+ static String random(int bytes) {
60+ byte[] b = new byte[bytes];
61+ RANDOM.nextBytes(b);
62+ return b64url(b);
63+ }
64+
65+ static String challenge(String verifier) {
66+ try {
67+ return b64url(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
68+ } catch (NoSuchAlgorithmException e) {
69+ throw new IllegalStateException(e);
70+ }
71+ }
72+
73+ static String enc(String v) {
74+ return URLEncoder.encode(v, StandardCharsets.UTF_8);
75+ }
76+
77+ static String form(Map<String, String> fields) {
78+ StringBuilder b = new StringBuilder();
79+ for (Map.Entry<String, String> e : fields.entrySet()) {
80+ if (!b.isEmpty()) b.append('&');
81+ b.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
82+ }
83+ return b.toString();
84+ }
85+
86+ static String authorizeUrl(String server, String redirectUri, String challenge, String state) {
87+ Map<String, String> q = new LinkedHashMap<>();
88+ q.put("response_type", "code");
89+ q.put("client_id", clientId(server));
90+ q.put("redirect_uri", redirectUri);
91+ q.put("code_challenge", challenge);
92+ q.put("code_challenge_method", "S256");
93+ q.put("state", state);
94+ q.put("scope", "write");
95+ return server + "/oauth/authorize?" + form(q);
96+ }
97+
98+ static Map<String, String> query(String raw) {
99+ Map<String, String> m = new LinkedHashMap<>();
100+ if (raw == null || raw.isEmpty()) return m;
101+ for (String part : raw.split("&")) {
102+ int eq = part.indexOf('=');
103+ String k = URLDecoder.decode(eq < 0 ? part : part.substring(0, eq), StandardCharsets.UTF_8);
104+ String v = eq < 0 ? "" : URLDecoder.decode(part.substring(eq + 1), StandardCharsets.UTF_8);
105+ m.putIfAbsent(k, v);
106+ }
107+ return m;
108+ }
109+
110+ /**
111+ * The code from the server's answer, after checking it belongs to this sign-in (state) and comes from this server
112+ * (RFC 9207 iss). Throws with a sentence for the person otherwise.
113+ */
114+ static String readCallback(String rawQuery, String state, String server) {
115+ Map<String, String> q = query(rawQuery);
116+ if (!state.equals(q.get("state"))) throw new IllegalStateException("The sign-in response didn't match this sign-in. Try again.");
117+ String iss = q.get("iss");
118+ if (iss != null && !iss.replaceAll("/+$", "").equals(server)) throw new IllegalStateException("The sign-in response came from " + iss + ", not " + server + ".");
119+ String err = q.get("error");
120+ if (err != null) {
121+ if (err.equals("access_denied")) throw new IllegalStateException("Sign-in was cancelled in the browser.");
122+ String d = q.get("error_description");
123+ throw new IllegalStateException("Diffinery refused the sign-in: " + (d == null || d.isEmpty() ? err : d));
124+ }
125+ String code = q.get("code");
126+ if (code == null || code.isEmpty()) throw new IllegalStateException("The sign-in response had no code. Try again.");
127+ return code;
128+ }
129+
130+ /** {owner, repo} from "owner/repo", or from a web or clone URL on this server; null for anything else. */
131+ static String[] repoRef(String server, String input) {
132+ String s = input.trim();
133+ String path;
134+ boolean url = SCHEME.matcher(s).find();
135+ if (url) {
136+ URI u;
137+ try {
138+ u = new URI(s);
139+ } catch (Exception e) {
140+ return null;
141+ }
142+ URI base = URI.create(server);
143+ if (u.getRawAuthority() == null || !u.getRawAuthority().equalsIgnoreCase(base.getRawAuthority())) return null;
144+ String bp = base.getRawPath() == null ? "" : base.getRawPath().replaceAll("/+$", "");
145+ String p = u.getRawPath() == null ? "" : u.getRawPath();
146+ if (!p.startsWith(bp)) return null;
147+ path = p.substring(bp.length());
148+ } else {
149+ path = s;
150+ }
151+ String[] parts = path.replaceAll("^/+|/+$", "").split("/");
152+ if (parts.length < 2 || (parts.length > 2 && !url)) return null;
153+ String owner = parts[0];
154+ String repo = parts[1].replaceAll("\\.git$", "");
155+ if (!NAME.matcher(owner).matches() || !NAME.matcher(repo).matches() || repo.equals(".") || repo.equals("..")) return null;
156+ return new String[] {owner, repo};
157+ }
158+
159+ /** Whether a repository matches what someone typed: every word in its full name or description, ignoring case. */
160+ static boolean matches(Repo r, String query) {
161+ String hay = (r.fullName() + " " + (r.description() == null ? "" : r.description())).toLowerCase(Locale.ROOT);
162+ for (String w : query.toLowerCase(Locale.ROOT).trim().split("\\s+")) if (!w.isEmpty() && !hay.contains(w)) return false;
163+ return true;
164+ }
165+
166+ /** The next page from a Link header (RFC 8288), as GitHub-style APIs send it. */
167+ static String nextLink(List<String> headers) {
168+ if (headers == null) return null;
169+ Pattern p = Pattern.compile("<([^>]+)>\\s*;\\s*rel=\"?next\"?");
170+ for (String h : headers)
171+ for (String part : h.split(",")) {
172+ Matcher m = p.matcher(part);
173+ if (m.find()) return m.group(1);
174+ }
175+ return null;
176+ }
177+
178+ /** A folder name for a clone, from a repository name. */
179+ static String folderName(String repo) {
180+ String n = repo.replaceAll("[^A-Za-z0-9._-]+", "-").replaceAll("^[-.]+|[-.]+$", "");
181+ return n.isEmpty() ? "repository" : n;
182+ }
183+}
addedapps/jetbrains/src/main/resources/META-INF/plugin.xml+43−0View file
@@ -1,0 +1,43 @@
1+<idea-plugin>
2+ <id>com.diffinery</id>
3+ <name>Diffinery</name>
4+ <vendor url="https://diffinery.com">Diffinery</vendor>
5+ <description><![CDATA[
6+ <p>Work with <a href="https://diffinery.com">Diffinery</a> repositories in IntelliJ IDEA, PyCharm, WebStorm,
7+ GoLand and the other JetBrains IDEs, as you do with GitHub repositories.</p>
8+ <ul>
9+ <li><b>Clone from Diffinery.</b> <b>Get from Version Control</b> lists <b>Diffinery</b> next to GitHub: your
10+ repositories and your organizations', with search, or paste <code>owner/name</code>.</li>
11+ <li><b>Sign in with your passkey.</b> The IDE opens diffinery.com in your browser, you approve the connection,
12+ and you're back in the IDE. Nothing to copy.</li>
13+ <li><b>Push and pull without passwords.</b> When Git asks for a password for Diffinery, the plugin answers with
14+ your signed-in account, and renews its token before it runs out.</li>
15+ <li><b>Share Project on Diffinery.</b> Publish a local Git repository as a new Diffinery repository.</li>
16+ </ul>
17+ <p>Guide: <a href="https://diffinery.com/docs/repositories/clone-in-jetbrains-ides">Clone in JetBrains IDEs</a>.</p>
18+ ]]></description>
19+
20+ <depends>com.intellij.modules.platform</depends>
21+ <depends>com.intellij.modules.vcs</depends>
22+ <depends>Git4Idea</depends>
23+
24+ <extensions defaultExtensionNs="com.intellij">
25+ <openapi.vcs.ui.cloneDialog.VcsCloneDialogExtension implementation="com.diffinery.jetbrains.CloneExtension"/>
26+ <applicationConfigurable parentId="project.propVCSSupport.Mappings" instance="com.diffinery.jetbrains.SettingsPage" id="com.diffinery.settings" displayName="Diffinery"/>
27+ <notificationGroup id="Diffinery" displayType="BALLOON"/>
28+ </extensions>
29+
30+ <extensions defaultExtensionNs="Git4Idea">
31+ <GitHttpAuthDataProvider implementation="com.diffinery.jetbrains.GitAuth"/>
32+ </extensions>
33+
34+ <actions>
35+ <action id="Diffinery.Clone" class="com.diffinery.jetbrains.CloneAction" text="Clone from Diffinery…" description="Clone one of your Diffinery repositories" icon="/icons/diffinery.svg">
36+ <add-to-group group-id="Git.MainMenu" anchor="last"/>
37+ </action>
38+ <action id="Diffinery.Share" class="com.diffinery.jetbrains.ShareAction" text="Share Project on Diffinery…" description="Create a Diffinery repository for this project and push it" icon="/icons/diffinery.svg">
39+ <add-to-group group-id="Vcs.Import" anchor="last"/>
40+ <add-to-group group-id="Git.MainMenu" anchor="last"/>
41+ </action>
42+ </actions>
43+</idea-plugin>
addedapps/jetbrains/src/main/resources/META-INF/pluginIcon.svg+1−0View file
@@ -1,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" rx="9" fill="#f6821f"/><g transform="translate(8 8)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></g></svg>
addedapps/jetbrains/src/main/resources/META-INF/pluginIcon_dark.svg+1−0View file
@@ -1,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" rx="9" fill="#f6821f"/><g transform="translate(8 8)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></g></svg>
addedapps/jetbrains/src/main/resources/icons/diffinery.svg+1−0View file
@@ -1,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6C707E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>
addedapps/jetbrains/src/main/resources/icons/diffinery_dark.svg+1−0View file
@@ -1,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#CED0D6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>
addedapps/jetbrains/src/test/java/com/diffinery/jetbrains/JsonTest.java+42−0View file
@@ -1,0 +1,42 @@
1+package com.diffinery.jetbrains;
2+
3+import static org.junit.Assert.assertEquals;
4+import static org.junit.Assert.assertFalse;
5+import static org.junit.Assert.assertNull;
6+import static org.junit.Assert.assertThrows;
7+import static org.junit.Assert.assertTrue;
8+
9+import java.util.List;
10+import java.util.Map;
11+import org.junit.Test;
12+
13+public class JsonTest {
14+ @Test
15+ public void readsApiResponses() {
16+ Map<String, Object> o = Json.object(Json.parse("{\"login\":\"ada\",\"id\":42,\"site_admin\":false,\"private\":true,\"bio\":null,\"tags\":[\"a\",{\"b\":1.5e1}],\"name\":\"Ada \\\"L\\\" \\u00e9\\n\"}"));
17+ assertEquals("ada", Json.string(o, "login"));
18+ assertEquals(42, Json.number(o, "id", -1));
19+ assertFalse(Json.bool(o, "site_admin"));
20+ assertTrue(Json.bool(o, "private"));
21+ assertNull(Json.string(o, "bio"));
22+ assertEquals("Ada \"L\" é\n", Json.string(o, "name"));
23+ List<Object> tags = Json.array(o.get("tags"));
24+ assertEquals("a", tags.get(0));
25+ assertEquals(15, Json.number(Json.object(tags.get(1)), "b", -1));
26+ assertEquals(-1, Json.number(o, "missing", -1));
27+ assertEquals(List.of(), Json.array(o.get("login")));
28+ }
29+
30+ @Test
31+ public void rejectsBrokenJson() {
32+ assertThrows(RuntimeException.class, () -> Json.parse("{\"a\":1"));
33+ assertThrows(RuntimeException.class, () -> Json.parse("{\"a\":1} x"));
34+ assertThrows(RuntimeException.class, () -> Json.parse("<html>"));
35+ }
36+
37+ @Test
38+ public void quotesWhatItWrites() {
39+ String q = Json.quote("to\"k\\en\n\u0001");
40+ assertEquals("to\"k\\en\n\u0001", Json.string(Json.object(Json.parse("{\"t\":" + q + "}")), "t"));
41+ }
42+}
addedapps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+95−0View file
@@ -1,0 +1,95 @@
1+package com.diffinery.jetbrains;
2+
3+import static org.junit.Assert.assertArrayEquals;
4+import static org.junit.Assert.assertEquals;
5+import static org.junit.Assert.assertFalse;
6+import static org.junit.Assert.assertNull;
7+import static org.junit.Assert.assertThrows;
8+import static org.junit.Assert.assertTrue;
9+
10+import java.util.List;
11+import java.util.Map;
12+import org.junit.Test;
13+
14+public class UtilTest {
15+ private static final String S = "https://diffinery.com";
16+
17+ @Test
18+ public void serverUrls() {
19+ assertEquals(S, Util.normalizeServer(null));
20+ assertEquals(S, Util.normalizeServer(" "));
21+ assertEquals(S, Util.normalizeServer("diffinery.com"));
22+ assertEquals(S, Util.normalizeServer("https://diffinery.com/"));
23+ assertEquals("https://git.example.com:8443/forge", Util.normalizeServer("https://git.example.com:8443/forge/"));
24+ assertEquals("http://localhost:8787", Util.normalizeServer("http://localhost:8787"));
25+ assertEquals("http://127.0.0.1:8787", Util.normalizeServer("http://127.0.0.1:8787"));
26+ // Plain http only on this computer: a token sent anywhere else would travel in the clear.
27+ assertThrows(IllegalArgumentException.class, () -> Util.normalizeServer("http://diffinery.com"));
28+ assertThrows(IllegalArgumentException.class, () -> Util.normalizeServer("ftp://diffinery.com"));
29+ assertEquals("diffinery.com", Util.host(S));
30+ assertEquals("localhost:8787", Util.host("http://localhost:8787"));
31+ }
32+
33+ @Test
34+ public void authorizeUrl() {
35+ String url = Util.authorizeUrl(S, "http://127.0.0.1:5000/callback", "ch", "st");
36+ assertTrue(url.startsWith(S + "/oauth/authorize?"));
37+ Map<String, String> q = Util.query(url.substring(url.indexOf('?') + 1));
38+ assertEquals("code", q.get("response_type"));
39+ assertEquals(S + "/oauth/clients/jetbrains.json", q.get("client_id"));
40+ assertEquals("http://127.0.0.1:5000/callback", q.get("redirect_uri"));
41+ assertEquals("S256", q.get("code_challenge_method"));
42+ assertEquals("st", q.get("state"));
43+ assertEquals("write", q.get("scope"));
44+ }
45+
46+ @Test
47+ public void pkceChallenge() {
48+ // RFC 7636, appendix B.
49+ assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", Util.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"));
50+ assertEquals(43, Util.random(32).length());
51+ }
52+
53+ @Test
54+ public void callback() {
55+ assertEquals("abc", Util.readCallback("code=abc&state=st&iss=https%3A%2F%2Fdiffinery.com", "st", S));
56+ assertEquals("abc", Util.readCallback("code=abc&state=st", "st", S));
57+ assertThrows(IllegalStateException.class, () -> Util.readCallback("code=abc&state=other", "st", S));
58+ assertThrows(IllegalStateException.class, () -> Util.readCallback("code=abc&state=st&iss=https%3A%2F%2Fevil.example", "st", S));
59+ IllegalStateException denied = assertThrows(IllegalStateException.class, () -> Util.readCallback("error=access_denied&state=st", "st", S));
60+ assertEquals("Sign-in was cancelled in the browser.", denied.getMessage());
61+ assertThrows(IllegalStateException.class, () -> Util.readCallback("state=st", "st", S));
62+ }
63+
64+ @Test
65+ public void repositoryReferences() {
66+ assertArrayEquals(new String[] {"ada", "engine"}, Util.repoRef(S, "ada/engine"));
67+ assertArrayEquals(new String[] {"ada", "engine"}, Util.repoRef(S, " https://diffinery.com/ada/engine "));
68+ assertArrayEquals(new String[] {"ada", "engine"}, Util.repoRef(S, "https://diffinery.com/ada/engine.git"));
69+ assertArrayEquals(new String[] {"ada", "engine"}, Util.repoRef(S, "https://diffinery.com/ada/engine/tree/main/src"));
70+ assertArrayEquals(new String[] {"ada", "engine"}, Util.repoRef("https://git.example.com/forge", "https://git.example.com/forge/ada/engine"));
71+ assertNull(Util.repoRef(S, "https://github.com/ada/engine"));
72+ assertNull(Util.repoRef(S, "ada"));
73+ assertNull(Util.repoRef(S, "ada/engine/extra"));
74+ assertNull(Util.repoRef(S, "ada/.."));
75+ assertNull(Util.repoRef(S, "a da/engine"));
76+ }
77+
78+ @Test
79+ public void searchAndPaging() {
80+ Repo r = new Repo("ada/difference-engine", "Gears and columns", false, false, false, "", "", "main");
81+ assertTrue(Util.matches(r, "engine"));
82+ assertTrue(Util.matches(r, "ADA gears"));
83+ assertFalse(Util.matches(r, "engine loom"));
84+ assertEquals("https://diffinery.com/api/v3/user/repos?page=2", Util.nextLink(List.of("<https://diffinery.com/api/v3/user/repos?page=2>; rel=\"next\", <https://diffinery.com/api/v3/user/repos?page=5>; rel=\"last\"")));
85+ assertNull(Util.nextLink(List.of("<https://diffinery.com/x?page=1>; rel=\"prev\"")));
86+ assertNull(Util.nextLink(null));
87+ }
88+
89+ @Test
90+ public void folderNames() {
91+ assertEquals("engine", Util.folderName("engine"));
92+ assertEquals("my-engine", Util.folderName("my engine"));
93+ assertEquals("repository", Util.folderName(".."));
94+ }
95+}