Cross-platform UI in GitHub Desktop
The comparison graph is the centerpiece of GitHub Desktop. It both drives the interaction with your branch and shows you the effects of your changes relative to a base branch.…
The comparison graph is the centerpiece of GitHub Desktop. It both drives the interaction with your branch and shows you the effects of your changes relative to a base branch.
It’s easily the most sophisticated piece of user interface in the app, with explanatory animations revealing the effects of commits, syncs, and merges.
With separate code bases for OS X & Windows converging on a single design, we knew that sharing code would be essential going forward. Sophisticated as the graph is, implementing it twice would have been a significant burden.
Cross-platform code isn’t entirely new to us: both code bases use git
& libgit2, for example. Sharing UI in a way that respects the conventions of each platform is a different matter, however.
Fortunately, the comparison graph had been prototyped as a reusable web component within an Electron app. We knew that we could host that implementation within a native web view on OS X, and experiments in Windows proved promising as well.
Here’s how we did it.
Shared implementations
Both of the GitHub Desktop implementations reference the comparison graph as a submodule. This brings in the full HTML/CoffeeScript/SASS sources for the comparison graph and the tutorial, as well as the tests and build scripts.
Much of that is unnecessary to running the app, so a build script compiles the sources down to distribution-ready HTML/JavaScript/CSS files which we embed in the apps.
On OS X, we use Apple’s WebView
in the UI to integrate the comparison graph. On Windows, our initial explorations with the .NET WebBrowser
control found it to be unworkable, so we embed CefSharp and use its ChromiumWebBrowser
. While this adds to our download size, both WebView
and ChromiumWebBrowser
are derived from WebKit, which makes it vastly simpler for us to develop and test changes to the comparison graph. In general someone working on the comparison graph on OS X doesn’t have to worry about whether it will work on Windows, and vice versa.
Shared interfaces
We do have to consider the comparison graph’s API on both platforms, however, since breaking changes there will need to be integrated in both places. Since breaking changes are a result of prior discussion, this is a matter of calling them out in the pull request and making sure that someone familiar with the other platform has had a chance to comment.
We’ve also managed this sort of churn by making a conscious effort to keep the comparison graph API narrow and focused. The general flow is something like this:
- The app fetches the current & base branches.
- The app asks the comparison graph to draw a comparison.
- The comparison graph asks its branch delegate (the app) for a batch of commits along each branch.
- The app fetches these commits and sends them off to the comparison graph.
- Repeat steps 3 & 4 until the comparison graph has either filled the width of the view (plus a little padding) or reached the branch point (where the branches converge), indicated by the app sending zero commits.
- If the comparison graph hasn’t already found the branch point, repeat step 3 through 5 when scrolling or resizing leftwards exposes more of the graph.
The comparison graph can also draw a single branch, or a pull request. These differ somewhat in effect, but the overall flow is very similar.
There are a few additional interactions which the apps need to support:
- When new commits appear, whether by syncing or committing, the app pushes them to the comparison graph.
- The comparison graph tells its interaction delegate (the app) when the person has hovered over or selected a commit or clicked a button.
- The actions performed by a button should generally be asynchronous, so the comparison graph can also display a message to indicate what the app is busy doing. The app tells the comparison graph what message to display, and when to stop displaying it.
- The app can change the buttons, e.g. changing the “Publish” button to “Sync” when a branch has been published to the remote.
Since both apps have to implement their side of this flow, they also share the rough architecture surrounding it. Broadly, this involves:
- Computing a value representing the state of the graph when something has changed (e.g. the person has made a commit).
- Diffing successive states.
- Interpreting the differences by asking the comparison graph to perform some action(s). For example, if two states involve different branches, the apps ask the comparison graph to draw a new comparison (or branch, or pull request). If they involve the same branches, but with changes to the commits, the apps instead ask the comparison graph to insert the new commits incrementally.
Unshared implementations
Although the overall flow and architecture are shared, the implementations are often quite different. For example, although both platforms use WebKit, the bridging APIs are very different.
On OS X, the JavaScriptCore APIs allow us to pass arbitrary objects, functions, and arrays between Objective-C/Swift and JavaScript. Native code can call methods on JavaScript objects, and JavaScript code can call methods on native objects, without any special handling. We can also define protocols to specify which properties of our objects should be bridged.
CefSharp, on the other hand, does not bridge arrays or functions. JavaScript objects can call methods on native objects, but not on the properties of native objects. Native objects cannot call methods on JavaScript objects; you ask the ChromiumWebBrowser
to evaluate a string of JavaScript source instead. Since the comparison graph is largely asynchronous, requiring both callbacks and arrays, this motivates a set of JavaScript shims baked into the comparison graph at build time.
On load, the shims set themselves up as the comparison graph’s delegate. When the comparison graph asks for more commits, it passes a callback which the commits should be passed to. The shims add this callback to a private table under a computed key, and call into the app, passing that key. Once the app has loaded the commits, it calls the callback, passing the same key and an array of commits.
shims.getCommitsBefore = function (name, sha, callback) {
callbacks[key(name, sha)] = callback;
window.native.getCommitsBefore(name, sha);
}
Since CefSharp doesn’t bridge arrays, this involves serializing the app’s model objects representing the commits into the JSON representation which the comparison graph expects. Compared to the process on OS X (where arrays of commit objects can be bridged directly), this is slightly less convenient, but has the advantage that the comparison graph and the app are mutually insulated from any changes to state that the other might wish to make.
Browser.RunJsAsync("shims.didGetCommitsBefore("
+ ToJson(name) + ", "
+ ToJson(sha) + ", "
+ ToJson(commits)
+ ")");
Finally, the app calls into the shims, passing the serialized array of commits, and the shims forward the commits to the comparison graph.
shims.didGetCommitsBefore = function (name, sha, commits) {
callbacks[key(name, sha)](commits);
delete callbacks[key(name, sha)];
}
We also employ CSS shims to adjust the comparison graph’s appearance to suit the look and feel of the host platform. For example, on Windows, the buttons have a flat appearance, while on OS X they have a slight gradient. Likewise, text is in Segoe UI on Windows, but Helvetica Neue on OS X. This helps us to ensure that sharing effort between the platform doesn’t result in a lowest common denominator experience.
Future proof
Beyond the comparison graph, the tutorial, and some shared architecture, most of the implementation remains distinct on both platforms. Perhaps the most significant benefit of the approach we took was that it enabled us to converge GitHub for Mac and GitHub for Windows into GitHub Desktop iteratively, rather than as a ground-up rewrite. That in turn enabled us to keep shipping updates while working on GitHub Desktop without having to divide our attention overmuch.
Having iterated our way here, we’re going to iterate onwards. There’s lots more in store for the comparison graph, and lots more that we’d like to share between the platforms. Here’s to 1.1!
Written by
Related posts
Inside the research: How GitHub Copilot impacts the nature of work for open source maintainers
An interview with economic researchers analyzing the causal effect of GitHub Copilot on how open source maintainers work.
OpenAI’s latest o1 model now available in GitHub Copilot and GitHub Models
The December 17 release of OpenAI’s o1 model is now available in GitHub Copilot and GitHub Models, bringing advanced coding capabilities to your workflows.
Announcing 150M developers and a new free tier for GitHub Copilot in VS Code
Come and join 150M developers on GitHub that can now code with Copilot for free in VS Code.