UI Plugins

Create custom user interface elements for Lokus. Add panels, toolbars, menus, status bar items, and more.

UI Extension Types

Lokus supports multiple UI extension points:

  • Panels - Sidebar and bottom panels with custom content
  • Webview Panels - Full-featured web views with HTML/CSS/JS
  • Status Bar Items - Information and controls in the status bar
  • Toolbars - Custom toolbar buttons
  • Menus - Context menu items
  • Dialogs - Modal and non-modal dialogs
  • Tree Views - Hierarchical data displays

Creating Panels

Basic Panel

export default class UIPanelPlugin implements Plugin {
  async activate(context: PluginContext) {
    // Register a simple panel
    context.subscriptions.push(
      context.api.ui.registerPanel({
        id: 'myPlugin.panel',
        title: 'My Panel',
        type: 'webview',
        location: 'sidebar',
        icon: 'star',
        html: this.getPanelHTML()
      })
    )
  }
 
  private getPanelHTML(): string {
    return `
      <!DOCTYPE html>
      <html>
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <style>
            body {
              padding: 20px;
              font-family: var(--font-family);
              color: var(--text-color);
              background: var(--background-color);
            }
            .header {
              font-size: 18px;
              font-weight: bold;
              margin-bottom: 16px;
            }
            .button {
              background: var(--primary-color);
              color: white;
              border: none;
              padding: 8px 16px;
              border-radius: 4px;
              cursor: pointer;
            }
            .button:hover {
              opacity: 0.9;
            }
          </style>
        </head>
        <body>
          <div class="header">Welcome to My Panel</div>
          <button class="button" onclick="handleAction()">
            Click Me
          </button>
 
          <script>
            function handleAction() {
              // Send message to plugin
              window.parent.postMessage({
                command: 'action',
                data: { clicked: true }
              }, '*')
            }
 
            // Receive messages from plugin
            window.addEventListener('message', event => {
              if (event.data.command === 'update') {
                console.log('Update from plugin:', event.data)
              }
            })
          </script>
        </body>
      </html>
    `
  }
}

Interactive Webview Panel

class WebviewPanelPlugin implements Plugin {
  private panel?: WebviewPanel
 
  async activate(context: PluginContext) {
    const panel = context.api.ui.registerWebviewPanel({
      id: 'myPlugin.webview',
      title: 'Interactive Panel',
      type: 'webview',
      location: 'sidebar',
      icon: 'extensions',
      options: {
        enableScripts: true,
        localResourceRoots: [context.assetUri]
      }
    })
 
    this.panel = panel
 
    // Handle messages from webview
    panel.webview.onDidReceiveMessage(message => {
      this.handleMessage(message)
    })
 
    // Set initial content
    panel.webview.html = this.getWebviewContent(panel.webview)
 
    context.subscriptions.push(panel)
  }
 
  private handleMessage(message: any) {
    switch (message.command) {
      case 'getData':
        this.panel?.webview.postMessage({
          command: 'dataResponse',
          data: { items: this.getItems() }
        })
        break
 
      case 'itemClicked':
        console.log('Item clicked:', message.itemId)
        break
    }
  }
 
  private getWebviewContent(webview: Webview): string {
    // Convert local file URIs for webview
    const scriptUri = webview.asWebviewUri('/path/to/script.js')
    const styleUri = webview.asWebviewUri('/path/to/style.css')
 
    return `
      <!DOCTYPE html>
      <html>
        <head>
          <link rel="stylesheet" href="${styleUri}">
        </head>
        <body>
          <div id="root"></div>
          <script src="${scriptUri}"></script>
        </body>
      </html>
    `
  }
 
  private getItems() {
    return [
      { id: 1, name: 'Item 1' },
      { id: 2, name: 'Item 2' },
      { id: 3, name: 'Item 3' }
    ]
  }
}

React-based Panel

import React, { useState, useEffect } from 'react'
import { render } from 'react-dom'
 
// React component for panel
function PanelComponent({ api }) {
  const [items, setItems] = useState([])
  const [loading, setLoading] = useState(true)
 
  useEffect(() => {
    loadItems()
  }, [])
 
  async function loadItems() {
    setLoading(true)
    const data = await api.storage.get('items', [])
    setItems(data)
    setLoading(false)
  }
 
  async function addItem() {
    const name = await api.ui.showInputBox({
      prompt: 'Enter item name'
    })
 
    if (name) {
      const newItems = [...items, { id: Date.now(), name }]
      setItems(newItems)
      await api.storage.set('items', newItems)
    }
  }
 
  return (
    <div className="panel">
      <div className="header">
        <h2>My Items</h2>
        <button onClick={addItem}>Add Item</button>
      </div>
 
      {loading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {items.map(item => (
            <li key={item.id}>{item.name}</li>
          ))}
        </ul>
      )}
    </div>
  )
}
 
// Plugin
class ReactPanelPlugin implements Plugin {
  async activate(context: PluginContext) {
    const panel = context.api.ui.registerWebviewPanel({
      id: 'myPlugin.reactPanel',
      title: 'React Panel',
      type: 'webview',
      location: 'sidebar',
      options: {
        enableScripts: true
      }
    })
 
    // Render React component in webview
    const html = `
      <!DOCTYPE html>
      <html>
        <head>
          <style>
            /* Panel styles */
          </style>
        </head>
        <body>
          <div id="root"></div>
          <script>
            // React will be rendered here
          </script>
        </body>
      </html>
    `
 
    panel.webview.html = html
 
    // Use webview API bridge
    panel.webview.postMessage({
      command: 'init',
      api: context.api
    })
  }
}

Status Bar Items

Creating Status Bar Item

class StatusBarPlugin implements Plugin {
  private statusItem?: StatusBarItem
 
  async activate(context: PluginContext) {
    // Create status bar item
    this.statusItem = context.api.ui.registerStatusBarItem({
      id: 'myPlugin.status',
      text: '$(check) Ready',
      tooltip: 'Plugin Status',
      alignment: 'left',
      priority: 100
    })
 
    this.statusItem.show()
 
    // Update on editor changes
    context.api.editor.onDidChangeTextDocument(() => {
      this.updateStatus()
    })
 
    context.subscriptions.push(this.statusItem)
  }
 
  private async updateStatus() {
    const content = await this.context.api.editor.getContent()
    const wordCount = content.split(/\s+/).length
 
    this.statusItem!.text = `$(file-text) ${wordCount} words`
    this.statusItem!.tooltip = `Word count: ${wordCount}`
  }
}

Clickable Status Bar Item

// Create status item with command
const statusItem = api.ui.registerStatusBarItem({
  id: 'myPlugin.info',
  text: '$(info) Plugin Info',
  command: 'myPlugin.showInfo',
  alignment: 'right',
  priority: 50
})
 
// Register command
api.commands.register({
  id: 'myPlugin.showInfo',
  title: 'Show Plugin Info',
  handler: () => {
    api.ui.showDialog({
      title: 'Plugin Information',
      message: 'Detailed plugin information here',
      type: 'info'
    })
  }
})

Tree Views

Creating Tree View

class TreeViewPlugin implements Plugin {
  async activate(context: PluginContext) {
    // Register tree data provider
    context.subscriptions.push(
      context.api.ui.registerTreeDataProvider(
        'myPlugin.treeView',
        new MyTreeDataProvider()
      )
    )
 
    // Add to manifest contributes.views
    // "views": {
    //   "explorer": [
    //     {
    //       "id": "myPlugin.treeView",
    //       "name": "My Tree View"
    //     }
    //   ]
    // }
  }
}
 
class MyTreeDataProvider implements TreeDataProvider<TreeNode> {
  private _onDidChangeTreeData = new EventEmitter<TreeNode | undefined>()
  readonly onDidChangeTreeData = this._onDidChangeTreeData.event
 
  getTreeItem(element: TreeNode): TreeItem {
    return {
      label: element.label,
      collapsibleState: element.children
        ? TreeItemCollapsibleState.Collapsed
        : TreeItemCollapsibleState.None,
      iconPath: element.icon,
      command: element.command,
      contextValue: element.contextValue
    }
  }
 
  async getChildren(element?: TreeNode): Promise<TreeNode[]> {
    if (!element) {
      // Root nodes
      return [
        {
          label: 'Category 1',
          children: [
            { label: 'Item 1.1', command: { command: 'open', args: ['item1.1'] } },
            { label: 'Item 1.2', command: { command: 'open', args: ['item1.2'] } }
          ]
        },
        {
          label: 'Category 2',
          children: [
            { label: 'Item 2.1' }
          ]
        }
      ]
    }
 
    return element.children || []
  }
 
  refresh() {
    this._onDidChangeTreeData.fire(undefined)
  }
}
 
interface TreeNode {
  label: string
  children?: TreeNode[]
  icon?: string
  command?: { command: string; args?: any[] }
  contextValue?: string
}

Context Menus

Add items to context menus via manifest:

{
  "contributes": {
    "menus": {
      "editor/context": [
        {
          "command": "myPlugin.action",
          "when": "editorTextFocus",
          "group": "navigation"
        }
      ],
      "view/item/context": [
        {
          "command": "myPlugin.itemAction",
          "when": "view == myPlugin.treeView",
          "group": "inline"
        }
      ]
    }
  }
}

Custom Toolbar

// Register toolbar
context.api.ui.registerToolbar({
  id: 'myPlugin.toolbar',
  title: 'My Toolbar',
  location: 'editor',
  items: [
    {
      id: 'action1',
      command: 'myPlugin.action1',
      icon: 'rocket',
      title: 'Action 1',
      tooltip: 'Perform Action 1'
    },
    {
      id: 'action2',
      command: 'myPlugin.action2',
      icon: 'zap',
      title: 'Action 2'
    }
  ]
})

Notifications and Dialogs

Notifications

// Simple notification
await api.ui.showNotification('Operation completed', 'success')
 
// With actions
const result = await api.ui.showNotification(
  'File has unsaved changes',
  'warning',
  [
    { id: 'save', label: 'Save', primary: true },
    { id: 'discard', label: 'Discard' },
    { id: 'cancel', label: 'Cancel' }
  ]
)
 
if (result === 'save') {
  // Handle save
}

Dialogs

// Confirm dialog
const result = await api.ui.showDialog({
  title: 'Confirm Delete',
  message: 'Are you sure you want to delete this item?',
  type: 'question',
  buttons: [
    { id: 'delete', label: 'Delete', primary: true },
    { id: 'cancel', label: 'Cancel' }
  ]
})
 
// Input dialog
const name = await api.ui.showInputBox({
  prompt: 'Enter name',
  placeholder: 'Name...',
  validateInput: (value) => {
    if (!value) return 'Name is required'
    return null
  }
})
 
// Quick pick
const selected = await api.ui.showQuickPick([
  { label: 'Option 1', description: 'First option' },
  { label: 'Option 2', description: 'Second option' }
], {
  placeholder: 'Select an option',
  canPickMany: false
})

Progress Indicators

// With notification
await api.ui.withProgress(
  {
    location: 'notification',
    title: 'Processing',
    cancellable: true
  },
  async (progress, token) => {
    for (let i = 0; i < 100; i++) {
      if (token.isCancellationRequested) {
        return
      }
 
      progress.report({
        message: `Step ${i + 1}/100`,
        increment: 1
      })
 
      await doWork(i)
    }
  }
)
 
// Window progress (in status bar)
await api.ui.withProgress(
  {
    location: 'window',
    title: 'Loading data'
  },
  async (progress) => {
    progress.report({ message: 'Fetching data...' })
    const data = await fetchData()
 
    progress.report({ message: 'Processing data...' })
    await processData(data)
  }
)

Styling

Using CSS Variables

Lokus provides CSS variables for theming:

.my-panel {
  color: var(--text-color);
  background: var(--background-color);
  border: 1px solid var(--border-color);
  font-family: var(--font-family);
  font-size: var(--font-size);
}
 
.button {
  background: var(--primary-color);
  color: var(--primary-text-color);
}
 
.button:hover {
  background: var(--primary-hover-color);
}
 
.danger {
  color: var(--danger-color);
}
 
.success {
  color: var(--success-color);
}

Common Variables

/* Colors */
--primary-color
--secondary-color
--success-color
--warning-color
--danger-color
--info-color
 
/* Text */
--text-color
--text-muted-color
--text-disabled-color
 
/* Background */
--background-color
--background-secondary-color
--background-hover-color
 
/* Borders */
--border-color
--border-hover-color
 
/* Typography */
--font-family
--font-size
--font-size-small
--font-size-large
--line-height
 
/* Spacing */
--spacing-xs: 4px
--spacing-sm: 8px
--spacing-md: 16px
--spacing-lg: 24px
--spacing-xl: 32px

Best Practices

Performance

// Bad: Update on every keystroke
api.editor.onDidChangeTextDocument(() => {
  updatePanel()
})
 
// Good: Debounce updates
import { debounce } from 'lodash'
 
const debouncedUpdate = debounce(() => {
  updatePanel()
}, 300)
 
api.editor.onDidChangeTextDocument(() => {
  debouncedUpdate()
})

Accessibility

<!-- Use semantic HTML -->
<button aria-label="Close panel">
  <span aria-hidden="true">&times;</span>
</button>
 
<!-- Keyboard navigation -->
<div tabindex="0" role="button" onKeyPress={handleKeyPress}>
  Clickable div
</div>
 
<!-- Screen reader text -->
<span class="sr-only">Additional context for screen readers</span>

Resource Management

async deactivate() {
  // Dispose all UI elements
  this.statusItem?.dispose()
  this.panel?.dispose()
 
  // Clear timers
  if (this.timer) {
    clearInterval(this.timer)
  }
}

Examples

Resources