Getting side-by-side preview in a terminal app Hyper
Tags: hyper, electron, tutorial
Published: 2021-10-04 11:16:06

Hi, it's [Takuya](https://twitter.com/inkdrop_app).
I'm publishing [YouTube content](https://youtube.com/devaslife), sharing my dev workflows.

## Want a side-by-side preview in a terminal app

For web coding tutorials, it'd be great to show your coding and output side-by-side.
Typically, you would do that by simply arranging two windows: An editor and a browser.
But that's annoying because every time you make a tutorial, you have to align the windows side-by-side.

[Hyper](https://hyper.is/), a terminal app built with web standards, once had a built-in webview feature:

![Hyper's built-in webview feature](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/529pd3dsbyab86o7chvf.png)

As you can see above, it displays a preview on the right side of the terminal window.
It looks pretty neat.
So, you can make tutorials without having a separate window for preview.

But it looks like the feature has been removed for security reasons:

* [built-in hyperlinks alternative not working properly · Issue #2748 · vercel/hyper](https://github.com/vercel/hyper/issues/2748)

It was 3 years ago, a quite old issue.
I understand the risk of loading web pages in Electron apps.
However, `webview` of the recent [Electron prohibits NodeJS integration by default](https://www.electronjs.org/docs/api/webview-tag#nodeintegration). So, I think it's safe to embed in Hyper, especially when you understand what you do.

## Hacking Hyper to get back the built-in webview feature

So, I decided to get that feature back to Hyper, and successfully did it.
Here is how it looks like:

![Demo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i92cqgf20zmc8rlx1d5c.png) 

Demo video:

{% twitter 1444952485177683969 %}

### How to build

I've made `built-in-webview` branch for the hack:

https://github.com/craftzdog/hyper/tree/built-in-webview

Clone this repo and built it yourself as following.

```sh
npm i
npm run dev
# On another terminal session
npm run app
```

### How to use

Split the window right by pressing `Cmd-D`.
Then, type `echo <URL-to-open>` and hit `Return`.
Click the URL in the terminal.
Then, the pane becomes a webview that loads the URL.

## How I hacked Hyper

Check out [the diffs](https://github.com/craftzdog/hyper/compare/canary...craftzdog:built-in-webview?expand=1)

First, you have to allow `webview` tags in `app/ui/window.ts`:

```diff
   const winOpts: BrowserWindowConstructorOptions = {
     minWidth: 370,
     minHeight: 190,
     backgroundColor: toElectronBackgroundColor(cfg.backgroundColor || '#000'),
     titleBarStyle: 'hiddenInset',
     title: 'Hyper.app',
     // we want to go frameless on Windows and Linux
     frame: process.platform === 'darwin',
     transparent: process.platform === 'darwin',
     icon,
     show: Boolean(process.env.HYPER_DEBUG || process.env.HYPERTERM_DEBUG || isDev),
     acceptFirstMouse: true,
     webPreferences: {
       nodeIntegration: true,
       navigateOnDragDrop: true,
       enableRemoteModule: true,
-      contextIsolation: false
+      contextIsolation: false,
+      webviewTag: true
     },
     ...options_
   };
```

Hyper partially remains the old implementations. You can reuse them.
The terminal component already has `url` prop.
You can display `webview` when the component has `url` prop.

In `lib/components/term.tsx`, change the terminal component class like so:

```diff
@@ -430,18 +436,35 @@ export default class Term extends React.PureComponent<TermProps> {
         style={{padding: this.props.padding}}
         onMouseUp={this.onMouseUp}
       >
-        {this.props.customChildrenBefore}
-        <div ref={this.onTermWrapperRef} className="term_fit term_wrapper" />
-        {this.props.customChildren}
-        {this.props.search ? (
-          <SearchBox
-            search={this.search}
-            next={this.searchNext}
-            prev={this.searchPrevious}
-            close={this.closeSearchBox}
+        {this.props.url ? (
+          <webview
+            src={this.props.url}
+            style={{
+              background: '#fff',
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              display: 'inline-flex',
+              width: '100%',
+              height: '100%'
+            }}
           />
         ) : (
-          ''
+          <>
+            {this.props.customChildrenBefore}
+            <div ref={this.onTermWrapperRef} className="term_fit term_wrapper" />
+            {this.props.customChildren}
+            {this.props.search ? (
+              <SearchBox
+                search={this.search}
+                next={this.searchNext}
+                prev={this.searchPrevious}
+                close={this.closeSearchBox}
+              />
+            ) : (
+              ''
+            )}
+          </>
         )}

         <style jsx global>{`
```

And, change the URL click handler to dispatch an action instead of opening up the page in an external browser:

```diff
@@ -160,7 +160,13 @@ export default class Term extends React.PureComponent<TermProps> {
       this.term.loadAddon(
         new WebLinksAddon(
           (event: MouseEvent | undefined, uri: string) => {
-            if (shallActivateWebLink(event)) void shell.openExternal(uri);
+            // if (shallActivateWebLink(event)) void shell.openExternal(uri);
+            // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+            store.dispatch({
+              type: 'SESSION_URL_SET',
+              uid: props.uid,
+              url: uri
+            });
           },
           {
             // prevent default electron link handling to allow selection, e.g. via double-click
```

In `lib/reducers/sessions.ts`, add the reducer for `SESSION_URL_SET` like so:

```diff
@@ -11,7 +11,8 @@ import {
   SESSION_SET_XTERM_TITLE,
   SESSION_SET_CWD,
   SESSION_SEARCH,
-  SESSION_SEARCH_CLOSE
+  SESSION_SEARCH_CLOSE,
+  SESSION_URL_SET
 } from '../constants/sessions';
 import {sessionState, session, Mutable, ISessionReducer} from '../hyper';

@@ -129,6 +130,9 @@ const reducer: ISessionReducer = (state = initialState, action) => {
       }
       return state;

+    case SESSION_URL_SET:
+      return state.setIn(['sessions', action.uid, 'url'], action.url);
+
     default:
       return state;
   }
```

It works like a charm!

### Follow me online

* Check out my app called [Inkdrop - A Markdown note-taking app](https://www.inkdrop.app/)
* Subscribe Newsletter http://eepurl.com/dNgJo6
* Twitter https://twitter.com/inkdrop_app
* YouTube channel https://youtube.com/c/devaslife
* Blog https://blog.inkdrop.app/
* Discord community https://discord.gg/QfsG5Kj
* Instagram https://instagram.com/craftzdog