addons/isl/README.md
Interactive Smartlog (ISL) is an embeddable, web-based GUI for Sapling. See user documentation here.
The code for ISL lives in the addons folder:
| folder | use |
|---|---|
| isl | Front end UI written with React and Jotai |
| isl-server | Back end, which runs sl commands / interacts with the repo |
| isl-server/proxy | sl web CLI and server management |
| shared | Utils shared by other projects |
| components | Shareable component library |
| vscode | VS Code extension for Sapling, including ISL as a webview |
First run yarn to make sure all of the Node dependencies are installed.
Use this command from the addons/ folder to start ISL in development mode:
yarn dev browser --launch .
This does 3 things:
yarn start in isl/)yarn watch in isl-server/)yarn serve in isl-server, with some args). The server will open with . as the cwd. Use --launch /path/to/my/repo to use a different repository.The yarn dev command is a shorthand to running each of these in their own terminal.
Note: the client and server build jobs will watch for changes. The webpage will hot reload as changes are made. The server must be restarted to pick up changes.
Press R when running yarn dev browser --launch CWD to restart the server while leaving the build running.
To see more server output, you may sometimes want to use yarn dev browser WITHOUT --launch to build the client and server, and then launch the server yourself with yarn serve. This launches the local ISL server.
In the isl-server/ folder, run yarn serve --dev to start the server and open the browser.
You will have to manually restart it in order to pick up server changes.
This is the development mode equivalent of running sl web.
This launches a WebSocket Server to proxy requests between the server and the
client. The entry point code lives in the isl-server/proxy/ folder and is a
simple HTTP server that processes upgrade requests and forwards
them to the WebSocket Server that expects connections at /ws.
Note: When the server is started, it creates a token to prevent unwanted access.
--dev opens the browser on the port used by vite in yarn start
to ensure the client connects with the right token.
When developing, it's useful to add a few extra arguments to yarn serve:
yarn serve --dev --force --foreground --stdout
--dev: Connect to the vite dev build's hot-reloading front-end server (defaulting to 3000), even though this server will spawn on 3001.--force: Kill any other active ISL server running on this port, which makes sure it's the latest version of the code.--foreground: instead of spawning the server in the background, run it in the foreground. ctrl-c-ing the yarn serve process will kill this server.--stdout: when combined with --foreground, prints the server logs to stdout so you can read them directly in the yarn serve terminal output.--command sl: override the command to use for sl, for example you might use ./sl, or an alias to your local build like lsl, or hg for Meta-internal usesbuild-tar.py is a script to build production bundles and
package them into a single self-contained tar.xz that can be distributed
along with sl. It can be launched by the sl web command.
yarn build lets you build production bundles without watching for changes, in either
isl/ or isl-server/.
You can also use yarn dev --production to run both client & server yarn build.
Similarly to developing in the browser, you can use this command:
yarn dev vscode --launch .
This again does 3 things:
yarn watch-webview in vscode/)yarn watch-extension in vscode/)As with the server, you may want to launch vscode yourself. Just use yarn dev vscode to build without launching vscode.
See also ../vscode/CONTRIBUTING.md.
Run yarn test in the isl server to run client-side tests. These generally use
React Testing Library to "render" the UI in a node process, and check that the fake in-memory DOM is correct.
Sometimes, this can spit out very long errors, showing the entire DOM when some element is not found.
You can disable this by passing HIDE_RTL_DOM_ERRORS as an env var:
HIDE_RTL_DOM_ERRORS=1 yarn test
ISL is designed to be an opinionated UI. It does not implement every single feature or argument that the CLI supports.
Rather, it implements an intuitive UI by leveraging a subset of features of the sl CLI.
ISL aims to optimize common workflows and provide an intuitive UX around some advanced workflows.
The following sections describe how ISL is implemented.
sl web is a normal sl python command, which invokes the latest ISL built CLI.
isl-server/proxy/run-proxy.ts is the typescript entry point which is spawned by Python via node.
In development mode, you interact directly with run-proxy rather than dealing with sl web.
Note: there are slightly differences between the python sl web CLI args and the run-proxy CLI args.
In general, run-proxy exposes more options, most of which aren't needed by normal sl web users.ISL uses an embeddable Client / Server architecture.
sl web, VS Code extension host, Electron main)The server serves the client's static (html/js/css) files via HTTP. The client JavaScript then connects back to the server via WebSocket, where both sides can send and receive messages to communicate.
The client renders the UI and asks the server to actually do stuff. The client has no direct access to the filesystem or repository. The client can make normal web requests, but does not have access tokens to make authenticated requests to GitHub.
The client uses React (for rendering the UI) and Jotai (for state management). We use a combination of regular CSS and StyleX for styling.
The server is able to interact with the file system, spawn processes, run sl commands,
and make authenticated network requests to GitHub.
The server is also responsible for watching the repository for changes.
This will optionally use Watchman if it's installed.
If not, the server falls back to a polling mechanism, which polls on a variable frequency
which depends on if the UI is focused and visible.
The server shells out to the gh CLI to make authenticated requests to GitHub.
Most of the server's work is done by the Repository object, which represents a single Sapling repository.
This object also delegates to manage Watchman subscriptions and GitHub fetching.
To support running sl web in multiple repos / cwds at the same time, ISL supports reusing server instances.
When spawning an ISL server, if the port is already in use by an ISL server, that server will be reused.
Since the server acts like a normal http web server, it supports multiple clients connecting at the same time, both the static resources and WebSocket connections.
Repository instances inside the server are cached per repo root.
RepositoryCache manages Repositories by reference counting.
A Repository does not have its own cwd set. Rather, each reference to a Repository
via RepositoryCache has an associated cwd. This way, A single Repository instance is reused
even if accessed from multiple cwds within the same repo.
We treat each WebSocket connection as its own cwd, and each WebSocket connections has one reference
to a shared Repository via RepositoryCache.
Connecting multiple clients to the same sever at the same cwd is also supported. Server-side fetched data is sent to all relevant (same repo) clients, not just the one that made a request. Note that client-side cached data is not shared, which means optimistic state may not work as well in a second window for operations triggered in a different window.
After all clients are disconnected, the server auto-shutdowns after one minute with no remaining repositories which helps ensure that old ISL servers aren't reused.
Note that ISL exposes --kill and --force options to kill old servers and force a fresh server, to make
it easy to work around unexpectedly reusing old ISL servers.
The client sends messages to the server to run sl commands.
We must authenticate clients to ensure arbitrary websites or XSS attacks can't connect on localhost:3011 to run commands.
The approach we take is to generate a cryptographic token when a server is started.
Connecting via WebSocket to the server requires this token.
The token is included in the url generated by sl web, which allows URLs from sl web to connect successfully.
Because of this token, restarting the ISL server requires clicking a fresh link to use the new token. Once an ISL server stops running, its token is no longer valid.
In order to support reusing ISL servers, we must persist the server's token to disk,
so that later sl web invocations can find the right token to use.
This persisted data includes the token but also some other metadata about the server,
which is written to a permission-restricted file.
Detail: we have a second token we use to verify that a server running on a port is actually an ISL server, to prevent misleading/phishing "reuses" of a server.
ISL is designed to be embedded in multiple contexts. sl web is the default,
which is also the most complicated due to server reuse and managing tokens.
The Sapling VS Code extension's ISL webview is another example of an embedding. Other embeddings are possible, such as an Electron / Tauri standalone app, or other IDE extensions such as Android Studio.
To support running in multiple contexts, ISL has the notion of a Platform, on both the client and server, which contains embedding-specific implementations of a common API.
This includes things like opening a file. In the browser, the best we can do is use the OS default. Inside the VS Code extension, we always want to open with VS Code. Each platform can implement this to match their UX best. The Client's platform is where platform-specific code first runs. Some embeddings have their client platform send platform-specific messages to the server platform.
The "default" platform is the BrowserPlatform, used by sl web.
Custom platforms can be implemented either by:
run-proxy's --platform option (Android Studio does this)ISL started as a way to automatically re-run sl status and sl smartlog in a loop.
The UI should always feel up-to-date, even though it needs to run these commands
to actually fetch the data.
The client subscribes to this data, which the server is in charge of fetching automatically.
The server uses Watchman (if installed) to detect when:
.sl/dirstate has changed to indicate the list of commits has changed, so we should re-run sl log.sl status to look for uncommitted changes.
If Watchman is not installed, sl log and sl status are polled on an interval by WatchForChanges and based on window focus.Similarly, the server fetches new data from GitHub when the list of PRs changes, and refreshes by polling.
ISL defines an "Operation" as any mutating sl command, such as sl pull, sl rebase, sl goto, sl amend, sl add, etc. Non-examples include sl status, sl log, sl cat, sl diff.
The lifecycle of an operation looks like this:
Ready to run -> Preview -> Queued -> Running -> Optimistic state -> Completed
Critically, fetching data via sl log and sl status is separate from running operations.
We only get the "new" state of the world after both the operation has completed AND
sl log / sl status has run to provide us with the latest data.
This would cause the UI to appear laggy and out of date. Thus, we support using previews and optimistic to update the UI immediately.
To support this, ISL defines a "preview applier" function for every operation.
The preview applier function describes how the DAG of commits and uncommitted changes
would change as a result of running this operation.
(Detail: there's actually a separate preview applier function for uncommitted changes and the commit DAG
to ensure UI smoothness if sl log and sl status return data at different times)
This supports both:
Because sl log and sl status are run separately from an operation running,
the optimistic state preview applier must be used not just while the operation is running,
but also after it finishes up until we get new data from sl log / sl status.
Preview Appliers are functions which take a commit DAG and return a new commit DAG.
This allows us to stack the result of preview appliers on top of each other.
This trivially enables Queued Commands, which work like && on the CLI.
If an operation is ongoing, and we click a button to run another, it is queued up by the server to run next. The client then renders the DAG resulting from first running Operation 1's preview applier, then running Operation 2's preview applier.
Important detail here: if an operation references a commit hash, the queued version
of that operation will not yet know the new hash after the previous operation finishes.
For example, sl amend in the middle of a stack, then sl goto the top of the stack.
Thus, when telling the server to run an Operation we tag which args are revsets,
so they are replaced with max(successors(${revset})) so the hash is replaced
with the latest successor hash. If you intentionally target an obsolete commit, then the hash is used directly.
ISL has a built-in i18n system, however the only language currently implemented is en-US English.
t() and <T> functions convert English strings or keys into values for other languages in the isl/i18n/${languageCode} folders. To add support for a new language, add a new isl/i18n/${languageCode}/common.js
and provide translations for all the strings found by grepping for t() and <T> in isl.
This system can be improved later as new languages are supported.
There's a "Run & Debug isl-server" vscode build action which runs yarn serve --dev for you with a few additional arguments. When spawned from here, you can use breakpoints in VS Code to step through your server-side code.
Note that you should have the client & server rollup compilation jobs (described above) running before doing this (it currently won't compile for you, just launch yarn serve).
Attaching the client to VS Code debugger does not work as well as the server side. There is currently no launch task to launch the browser and connect to the debugger. You can try using "Debug: Open Link" from the command palette, and paste in the ISL server link (with the token included), but I found breakpoint line numbers don't match up correctly.
You can open the chrome devtools, go to sources, search for files, and set breakpoints in there,
which will mostly work. debugger; statements also work in the dev tools.
If you encounter a stack trace in production, it will be referencing minified line numbers like:
Error: something went wrong
at t (/some/production/path/to/isl-server/dist/run-proxy.js:1:4152)
We build/ship with source maps that sit next to source files, like isl-server/dist/run-proxy.js.map.
You can use these source maps to recover the real stack trace, using a tool like stacktracify.
$ npm install -g stacktracify
# copy minified stack trace to clipboard, then give the path to the source map:
$ stacktracify /path/to/isl-server/dist/run-proxy.js.map
Error: something went wrong
at from (webpack://isl-server/proxy/proxyUtils.ts:14:22)
Note that the source map you use must match the version in the original stack trace. Usually, you can tell the version by the path in the stack trace.
Client: To analyze the client bundle size (code splitting and dependencies, etc):
cd islnpx vite-bundle-visualizerShould also work in vscode/ for the webview code.
Server:
Install rollup-plugin-visualizer
and add it to the server's rollup.config.mjs, then yarn build and inspect the stats.html file.
Should also work for the vscode extension config.