Repository: stateful/vscode-runme
Pull request #373 (2023-04-03 18:29:49)

Fix notebook terminal resizing
I don't think it's necessary to cache position to ensure not running fit, since it needs to be run every resize anyway.

This PR will be needed for implementing terminal (PTY) resizing.

sourishkrout: Good point! I forgot to add this to the roadmap item

Patch:
diff --git a/package.json b/package.json
index b4e76982..cfe70006 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
   "activationEvents": [
     "onNotebook:runme",
     "onRenderer:runme-renderer",
+    "onCommand:runme.welcome",
     "onStartupFinished"
   ],
   "main": "./out/extension.js",
@@ -113,6 +114,18 @@
         "category": "Runme",
         "title": "Expand all items",
         "icon": "$(search-expand-results)"
+      },
+      {
+        "command": "runme.welcome",
+        "title": "Welcome",
+        "category": "Runme",
+        "description": "Open the welcome page for the Runme extension."
+      },
+      {
+        "command": "runme.try",
+        "title": "Try Runme",
+        "category": "Runme",
+        "description": "Walk through some interactive notebook examples."
       }
     ],
     "menus": {
diff --git a/src/client/components/terminal.ts b/src/client/components/terminal.ts
index 0d8eea93..ae544a16 100644
--- a/src/client/components/terminal.ts
+++ b/src/client/components/terminal.ts
@@ -9,12 +9,7 @@ import { Unicode11Addon } from 'xterm-addon-unicode11'
 import { ClientMessages } from '../../constants'
 import { getContext } from '../utils'
 import { onClientMessage, postClientMessage } from '../../utils/messaging'
-
-
-interface IWindowSize {
-  width: number
-  height: number
-}
+import { stripANSI } from '../../utils/ansi'
 
 const vscodeCSS = (...identifiers: string[]) => `--vscode-${identifiers.join('-')}`
 const terminalCSS = (id: string) => vscodeCSS('terminal', id)
@@ -219,13 +214,41 @@ export class TerminalView extends LitElement {
         z-index: 2;
         position: relative;
     }
+
+    vscode-button {
+      background: transparent;
+      color: #ccc;
+      transform: scale(.9);
+    }
+    vscode-button:hover {
+      background: var(--button-secondary-background);
+    }
+    vscode-button:focus {
+      outline: #007fd4 1px solid;
+    }
+    .icon {
+      width: 13px;
+      margin: 0 5px 0 -5px;
+      padding: 0;
+    }
+
+    .button-group {
+      display: flex;
+      flex-direction: row;
+      justify-content: end;
+    }
+
+    section {
+      display: flex;
+      flex-direction: column;
+      gap: 5px;
+    }
   `
 
   protected disposables: Disposable[] = []
   protected terminal?: XTermJS
   protected fitAddon?: FitAddon
   protected serializer?: SerializeAddon
-  protected windowSize: IWindowSize
 
   @property({ type: String })
   uuid?: string
@@ -244,10 +267,6 @@ export class TerminalView extends LitElement {
 
   constructor() {
     super()
-    this.windowSize = {
-      height: window.innerHeight,
-      width: window.innerWidth
-    }
   }
 
   connectedCallback(): void {
@@ -274,9 +293,9 @@ export class TerminalView extends LitElement {
     }
 
     this.fitAddon = new FitAddon()
+    this.fitAddon.activate(this.terminal!)
     this.serializer = new SerializeAddon()
     const unicode11Addon = new Unicode11Addon()
-    this.terminal.loadAddon(this.fitAddon)
     this.terminal.loadAddon(this.serializer)
     this.terminal.loadAddon(unicode11Addon)
     this.terminal.unicode.activeVersion = '11'
@@ -284,7 +303,7 @@ export class TerminalView extends LitElement {
 
     const ctx = getContext()
 
-    window.addEventListener('resize', this.#onResizeWindow)
+    window.addEventListener('resize', this.#onResizeWindow.bind(this))
 
     this.disposables.push(
       onClientMessage(ctx, (e) => {
@@ -319,8 +338,13 @@ export class TerminalView extends LitElement {
 
   protected firstUpdated(props: PropertyValues): void {
     super.firstUpdated(props)
-    const terminalContainer = this.#getTerminalElement()
-    this.terminal!.open(terminalContainer as HTMLElement)
+    const terminalContainer = this.#getTerminalElement() as HTMLElement
+
+    window.addEventListener('focus', () => {
+      this.terminal?.focus()
+    })
+
+    this.terminal!.open(terminalContainer)
     this.terminal!.focus()
     this.fitAddon?.fit()
     this.#updateTerminalTheme()
@@ -356,21 +380,43 @@ export class TerminalView extends LitElement {
   }
 
   #onResizeWindow(): void {
-    const { innerWidth } = window
-    // Prevent adjusting the terminal size if the width remains the same
-    if (this.windowSize.width !== innerWidth) {
-      this.windowSize.width = innerWidth
-      this.fitAddon?.fit()
-    }
+    this.fitAddon?.fit()
   }
 
   // Render the UI as a function of component state
   render() {
-    return html`<div id="terminal"></div>`
+    return html`<section>
+      <div id="terminal"></div>
+      <div class="button-group">
+        <vscode-button appearance="secondary" @click="${this.#copy.bind(this)}">
+          <svg
+            class="icon" width="16" height="16" viewBox="0 0 16 16"
+            xmlns="http://www.w3.org/2000/svg" fill="currentColor"
+          >
+            <path fill-rule="evenodd" clip-rule="evenodd"
+              d="M4 4l1-1h5.414L14 6.586V14l-1 1H5l-1-1V4zm9 3l-3-3H5v10h8V7z"/>
+            <path fill-rule="evenodd" clip-rule="evenodd"
+              d="M3 1L2 2v10l1 1V2h6.414l-1-1H3z"/>
+          </svg>
+          Copy
+        </vscode-button>
+      </div>
+    </section>`
   }
 
   dispose() {
     this.disposables.forEach(({ dispose }) => dispose())
   }
 
+  #copy() {
+    const ctx = getContext()
+    if (!ctx.postMessage) { return }
+
+    const content = stripANSI(this.serializer?.serialize({ excludeModes: true, excludeAltBuffer: true }) ?? '')
+
+    return navigator.clipboard.writeText(content).then(
+      () => postClientMessage(ctx, ClientMessages.infoMessage, 'Copied result content to clipboard!'),
+      (err) => postClientMessage(ctx, ClientMessages.infoMessage, `'Failed to copy to clipboard: ${err.message}!'`),
+    )
+  }
 }
diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts
index f8ac7947..0cc3e01e 100644
--- a/src/extension/commands/index.ts
+++ b/src/extension/commands/index.ts
@@ -3,8 +3,9 @@ import os from 'node:os'
 
 import {
   NotebookCell, Uri, window, env, NotebookDocument, TextDocument, ViewColumn,
-  workspace, NotebookData, commands, NotebookCellData, NotebookCellKind
+  workspace, NotebookData, commands, NotebookCellData, NotebookCellKind, ExtensionContext
 } from 'vscode'
+import { v4 as uuidv4 } from 'uuid'
 
 import { getBinaryPath, getTLSDir, getTLSEnabled, isNotebookTerminalEnabledForCell } from '../../utils/configuration'
 import { Kernel } from '../kernel'
@@ -137,3 +138,29 @@ export async function createNewRunmeNotebook () {
   )
   await commands.executeCommand('vscode.openWith', newNotebook.uri, Kernel.type)
 }
+
+export async function welcome () {
+  commands.executeCommand(
+    'workbench.action.openWalkthrough',
+    'stateful.runme#runme.welcome',
+    false
+  )
+}
+
+export async function tryIt (context: ExtensionContext) {
+  try {
+    const fileContent = await workspace.fs.readFile(
+      Uri.file(path.join(__dirname, '..', 'walkthroughs', 'welcome.md'))
+    )
+
+    const projectUri = Uri.joinPath(context.globalStorageUri, uuidv4())
+    await workspace.fs.createDirectory(projectUri)
+    const enc = new TextEncoder()
+    const newNotebookUri = Uri.joinPath(projectUri, 'Welcome to Runme.md')
+    await workspace.fs.writeFile(newNotebookUri, enc.encode(fileContent.toString()))
+    await commands.executeCommand('vscode.openWith', newNotebookUri, Kernel.type)
+  } catch (err) {
+    const localMarkdown = Uri.joinPath(Uri.file(context.extensionPath), 'walkthroughs', 'welcome.md')
+    return commands.executeCommand('vscode.openWith', localMarkdown, Kernel.type)
+  }
+}
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 64f90087..7c74d0a9 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -17,7 +17,9 @@ import {
   openAsRunmeNotebook,
   openSplitViewAsMarkdownText,
   stopBackgroundTask,
-  createNewRunmeNotebook
+  createNewRunmeNotebook,
+  welcome,
+  tryIt
 } from './commands'
 import { WasmSerializer, GrpcSerializer } from './serializer'
 import { RunmeLauncherProvider } from './provider/launcher'
@@ -94,6 +96,8 @@ export class RunmeExtension {
       RunmeExtension.registerCommand('runme.openSplitViewAsMarkdownText', openSplitViewAsMarkdownText),
       RunmeExtension.registerCommand('runme.openAsRunmeNotebook', openAsRunmeNotebook),
       RunmeExtension.registerCommand('runme.new', createNewRunmeNotebook),
+      RunmeExtension.registerCommand('runme.welcome', welcome),
+      RunmeExtension.registerCommand('runme.try', () => tryIt(context)),
       RunmeExtension.registerCommand('runme.openRunmeFile', RunmeLauncherProvider.openFile),
       RunmeExtension.registerCommand('runme.keybinding.m', () => { }),
       RunmeExtension.registerCommand('runme.keybinding.y', () => { }),
@@ -104,8 +108,8 @@ export class RunmeExtension {
        * tree viewer items
        */
       window.registerTreeDataProvider('runme.launcher', treeViewer),
-      commands.registerCommand('runme.collapseTreeView', treeViewer.collapseAll.bind(treeViewer)),
-      commands.registerCommand('runme.expandTreeView', treeViewer.expandAll.bind(treeViewer)),
+      RunmeExtension.registerCommand('runme.collapseTreeView', treeViewer.collapseAll.bind(treeViewer)),
+      RunmeExtension.registerCommand('runme.expandTreeView', treeViewer.expandAll.bind(treeViewer)),
 
       /**
        * Uri handler
diff --git a/src/extension/provider/launcher.ts b/src/extension/provider/launcher.ts
index bd8d4f14..17b9b89a 100644
--- a/src/extension/provider/launcher.ts
+++ b/src/extension/provider/launcher.ts
@@ -184,7 +184,8 @@ export class RunmeLauncherProvider implements TreeDataProvider<RunmeFile>, Dispo
     const rootFolder = basename(this.workspaceRoot || '')
     const info = basename(file.path)
     const folderPath = dirname(file.path)
-    const folderName = folderPath.replace(resolve(this.workspaceRoot || '', '..'), '') + nameTweaker || rootFolder
+    let folderName = folderPath.replace(resolve(this.workspaceRoot || '', '..'), '') + nameTweaker || rootFolder
+    folderName = folderName.startsWith('/') ? folderName.substring(1, folderName.length) : folderName
     return { info, folderPath, folderName }
   }
 
diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts
new file mode 100644
index 00000000..bb1c3f3a
--- /dev/null
+++ b/src/utils/ansi.ts
@@ -0,0 +1,6 @@
+export function stripANSI(src: string): string {
+  return src.replace(
+    /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
+    '',
+  )
+}
diff --git a/walkthroughs/0-intro.md b/walkthroughs/0-intro.md
index 0c4088db..40fe0018 100644
--- a/walkthroughs/0-intro.md
+++ b/walkthroughs/0-intro.md
@@ -4,6 +4,11 @@ Thank you for interest in Runme 💟!
 
 Runme strives to provide a great experience right out of the box. Please continue to learn how to get the most out of our docs via Runme. Please don't be surprised if Runme's docs prompt you to use Runme. Learning by using is very effective.
 
+<div>
+  <img width="15" src="https://runme.dev/img/logo.svg" />
+  <a href="command:runme.try">Give Runme a try</a>
+</div>
+
 A well-written README can significantly impact time to get up and running on your projects, reduce onboarding time, and interactively document common development and operations workflows (runbooks). On the contrary, a poorly written README can negatively impact the visibility of your project, even if the underyling code of your software is in excellent condition. For new contributors, it can quickly become a very frustrating experience.
 
 The Runme team believes that even if you have textually excellent README.md, encouraging your users to experience it through the lens of Runme, your docs will be more reliable, less susceptible to bit-rot, and a lot more humanly ergnomic. All of which leeds to happier and more productive developers.
diff --git a/walkthroughs/welcome.md b/walkthroughs/welcome.md
new file mode 100644
index 00000000..fc808e36
--- /dev/null
+++ b/walkthroughs/welcome.md
@@ -0,0 +1,27 @@
+# ![Runme](https://runme.dev/img/logo.svg "Runme") Welcome to Runme
+
+Runme is primary a VS Code extension that provides developers with the ability to navigate workflows center around code repositories by making Readme markdown files interactive and smart.
+
+It allows you to run Shell scripts within a Notebook interface, give it a try:
+
+```sh { interactive=false }
+echo "Hello Runme!"
+```
+
+Awesome! 🎉
+
+It also persist your session state, so all environment variables stay around, e.g.:
+
+```sh
+export RUNME_MESSAGE="<input message here>"
+```
+
+now run:
+
+```sh { interactive=false }
+echo $RUNME_MESSAGE
+```
+
+Nice! 👌
+
+There is a lot more of that. Checkout the [Runme docs](https://runme.dev) and checkout all features that this extension can help you with. Also, make sure to join our [Discord](https://discord.gg/BQm8zRCBUY) in case you have any questions or like to provide feedback!


Test patch:
diff --git a/tests/extension/commands/index.test.ts b/tests/extension/commands/index.test.ts
index c090683b..56e84a64 100644
--- a/tests/extension/commands/index.test.ts
+++ b/tests/extension/commands/index.test.ts
@@ -22,7 +22,9 @@ import {
   openAsRunmeNotebook,
   openSplitViewAsMarkdownText,
   stopBackgroundTask,
-  createNewRunmeNotebook
+  createNewRunmeNotebook,
+  welcome,
+  tryIt
 } from '../../../src/extension/commands'
 import { getTerminalByCell, getAnnotations, replaceOutput } from '../../../src/extension/utils'
 import { getBinaryPath, isNotebookTerminalEnabledForCell } from '../../../src/utils/configuration'
@@ -148,3 +150,29 @@ test('createNewRunmeNotebook', async () => {
   expect(NotebookCellData).toBeCalledTimes(3)
   expect(commands.executeCommand).toBeCalledWith('vscode.openWith', expect.any(String), 'runme')
 })
+
+test('welcome command', async () => {
+  await welcome()
+  expect(commands.executeCommand).toBeCalledWith(
+    'workbench.action.openWalkthrough',
+    'stateful.runme#runme.welcome',
+    false
+  )
+})
+
+test('tryIt command', async () => {
+  await tryIt({ globalStorageUri: { fsPath: '/foo/bar' } } as any)
+  expect(
+    vi.mocked(workspace.fs.createDirectory).mock.calls[0][0].path.startsWith('/foo/bar')
+  ).toBe(true)
+  expect(
+    vi.mocked(workspace.fs.writeFile).mock.calls[0][0].path.endsWith('Welcome to Runme.md')
+  ).toBe(true)
+  expect(commands.executeCommand).toBeCalledWith('vscode.openWith', expect.any(Object), 'runme')
+})
+
+test('tryIt command in failure case', async () => {
+  vi.mocked(workspace.fs.createDirectory).mockRejectedValue('ups')
+  await tryIt({ extensionPath: '/foo/bar' } as any)
+  expect(commands.executeCommand).toBeCalledWith('vscode.openWith', expect.any(Object), 'runme')
+})
diff --git a/tests/extension/extension.test.ts b/tests/extension/extension.test.ts
index bdd82849..c5899bfb 100644
--- a/tests/extension/extension.test.ts
+++ b/tests/extension/extension.test.ts
@@ -42,7 +42,7 @@ test('initializes all providers', async () => {
   await ext.initialize(context)
   expect(notebooks.registerNotebookCellStatusBarItemProvider).toBeCalledTimes(6)
   expect(workspace.registerNotebookSerializer).toBeCalledTimes(1)
-  expect(commands.registerCommand).toBeCalledTimes(14)
+  expect(commands.registerCommand).toBeCalledTimes(16)
   expect(window.registerTreeDataProvider).toBeCalledTimes(1)
   expect(window.registerUriHandler).toBeCalledTimes(1)
 
diff --git a/tests/extension/provider/launcher.test.ts b/tests/extension/provider/launcher.test.ts
index d8fd9cf3..fed6a7c8 100644
--- a/tests/extension/provider/launcher.test.ts
+++ b/tests/extension/provider/launcher.test.ts
@@ -101,7 +101,7 @@ describe('Runme Notebooks', () => {
     expect(workspace.createFileSystemWatcher).toBeCalledWith('**/*.md', false, true, false)
     handler.mock.calls[0][0]({ path: '/foo/bar' }, true)
     expect([...launchProvider['filesTree'].entries()])
-      .toEqual([['/foo ', { files: ['bar'], folderPath: '/foo' }]])
+      .toEqual([['foo ', { files: ['bar'], folderPath: '/foo' }]])
       handler.mock.calls[1][0]({ path: '/foo/bar' })
     expect(launchProvider.refresh).toBeCalledTimes(1)
   })