ecliptic

Writing extensions

ecliptic's extensions work the way VS Code's do: a folder with a package.json that says what the extension contributes (colour themes, settings, commands, menu entries, keybindings, views), and, if it needs to do something, a process of its own that the app talks to. An extension can reach every part of the app: the theme, the Settings editor, the title bar's and the columns' menus, the keyboard, the Activity Bar and the side bar, the status bar, the Changes rows, the diff drawer, the notice bar, the clipboard and the columns themselves.

The app ships three extensions, built in (crates/ecliptic/extensions/): VS Code Themes (ecliptic.vscode), which contributes Dark Modern and Light Modern (the defaults), Dark+ and Light+; Atlas Themes (ecliptic.atlas-themes), the Atlas-branded themes, with Atlas Ember; and Tokyo Night (ecliptic.tokyo-night), with Tokyo Midnight. The app itself knows only the two defaults: every other theme is an extension, and the picker lists the defaults first and the rest by name. There is a complete example in examples/extensions/change-stats: a status bar total, two views, decorations, commands, a menu entry, a keybinding and two settings, in 200 lines of Python.

Installing

An extension is a folder in ~/.local/share/ecliptic/extensions/ ($XDG_DATA_HOME/ecliptic/extensions/; on macOS ~/Library/Application Support/ecliptic/extensions/, on Windows %APPDATA%\ecliptic\extensions\). The Extensions view (the Ctrl+Shift+X icon in the Activity Bar) lists every extension, built in or installed, and turns each on or off, at once: what it contributes comes and goes, and its process starts or stops. Its folder icon opens the extensions folder. Click an extension for its page, as in VS Code: the header (name, publisher, version, Enable or Disable, the folder it is in and any manifest problems), Details (its README.md), Features (what the manifest contributes: colour themes, commands with their keybindings, settings with their defaults, keybindings, menus, view containers, views and its process) and Changelog (its CHANGELOG.md). The README and changelog are read as a small Markdown subset: headings, paragraphs, lists, fenced code, rules and quotes; links and images show their text, and HTML is dropped.

cp -r examples/extensions/change-stats ~/.local/share/ecliptic/extensions/

A folder that is not an extension (no package.json, or one that is not JSON) is listed under Could not load with the reason; the others load. An installed extension with a built-in's id replaces the built-in.

A VS Code colour-theme extension works as it is: unpack its .vsix (it is a zip; the extension is in its extension/ folder) into the extensions folder.

The manifest

{
    "name": "change-stats",                 // with publisher, the id: ecliptic-examples.change-stats
    "publisher": "ecliptic-examples",
    "version": "1.0.0",
    "displayName": "Change Stats",
    "description": "Lines added and removed across every column.",
    "main": "./extension.py",               // optional: its process
    "activationEvents": ["onStartupFinished"],
    "contributes": { … }
}

Comments and trailing commas are allowed. Fields the app does not use (engines, repository, icon, …) are read past. A contribution the app cannot use is reported (in the log and the Extensions view) and the rest of the manifest still loads.

Contribution points

themes

"themes": [
    { "label": "Night", "uiTheme": "vs-dark", "path": "./themes/night.json" }
]

uiTheme is vs (light), vs-dark, hc-black or hc-light. The file is a VS Code colour theme:

{
    "name": "Night",
    "include": "./night-base.json",      // merged under this file's colours
    "colors": {
        "sideBar.background": "#14161B",
        "statusBar.background": "#0E639C",
        "gitDecoration.modifiedResourceForeground": "#E5C07B"
    }
}

Colours are #RGB, #RGBA, #RRGGBB or #RRGGBBAA; null unsets one. tokenColors and semanticTokenColors (syntax colours) are read past. Every colour a theme does not set takes Dark Modern's (for a dark theme) or Light Modern's (for a light one), so a theme can set as few colours as it likes. The ids are VS Code's where VS Code has the colour, ecliptic.* otherwise; they are all at the end.

Themes are chosen with Preferences: Color Theme (Ctrl+K Ctrl+T), which previews each as the selection moves, or in settings: workbench.colorTheme, and workbench.preferredDarkColorTheme and workbench.preferredLightColorTheme while window.autoDetectColorScheme follows the desktop. workbench.colorCustomizations overrides colours on top of any theme, or one theme's: { "[Night]": { "focusBorder": "#FF0" } }.

configuration

"configuration": {
    "title": "Change Stats",
    "properties": {
        "changeStats.largeChange": { "type": "integer", "default": 200, "minimum": 1,
                                     "description": "Lines from which a file is marked." },
        "changeStats.mode": { "type": "string", "enum": ["lines", "files"], "default": "lines" }
    }
}

Types: boolean, integer and number (with minimum, maximum), string, string with enum, and array of strings. The settings appear in the Settings editor under the title, are stored in settings.json, and are checked as the built-in ones are: a value of the wrong type takes the default and the problem is shown. Its process gets its settings in initialize and again in configuration/changed.

commands

"commands": [
    { "command": "changeStats.showSummary", "title": "Show Summary",
      "category": "Change Stats", "icon": "$(graph)" },
    { "command": "changeStats.gitk", "title": "Open in gitk",
      "exec": ["gitk", "--all"] }
]

A command shows in the Command Palette (Ctrl+Shift+P or F1) as Category: Title. Running it starts the extension's process if it is not running, and sends it command/execute.

exec is ecliptic's own: the command runs that program instead, in the repository it was run on, with ${repository}, ${path} (relative), ${file} (absolute) and ${hash} replaced. It needs no process.

Icons are codicons by name: $(graph), $(beaker), $(git-commit). Every codicon is there.

"menus": {
    "scm/resourceState/context": [{ "command": "changeStats.fileStats", "when": "scmResourceState != D" }],
    "scm/historyItem/context":   [{ "command": "changeStats.blame" }],
    "scm/title":                 [{ "command": "changeStats.showSummary" }],
    "commandPalette":            [{ "command": "changeStats.fileStats", "when": "false" }]
}
Menu Where The command gets
scm/resourceState/context A file's menu in a column { repository, path, status, staged }
scm/historyItem/context A commit's menu in a column { repository, hash, subject }
scm/title A column's menu elsewhere { repository }
commandPalette "when": "false" keeps a command out of the palette

The entries follow the built-in ones, which the settings ecliptic.menu.file, ecliptic.menu.commit and ecliptic.menu.column list by command id; a user can drop or reorder those, not an extension's.

when clauses take context keys, ==, != (values bare or quoted), !, &&, || and parentheses. Keys: scmResourceState (the status letter: M, A, D, R, C, T, U, !), scmResourceGroup (index, workingTree), scmHistoryItem, sideBarVisible, activeViewlet, settingsEditorOpen, drawerOpen. A clause that cannot be read is false.

keybindings

"keybindings": [{ "command": "changeStats.showSummary", "key": "ctrl+alt+s", "when": "!settingsEditorOpen" }]

VS Code's spelling, chords included (ctrl+k ctrl+s); linux wins over key. An extension's keybinding wins over a built-in one with the same keys, and the user's keybindings.json wins over both (it can also remove an extension's binding, with "command": "-id"). args are passed to the command. File → Keyboard Shortcuts lists them all with their source.

viewsContainers, views, viewsWelcome

"viewsContainers": { "activitybar": [{ "id": "changeStats", "title": "Change Stats", "icon": "$(graph)" }] },
"views": { "changeStats": [{ "id": "changeStats.biggest", "name": "Biggest Changes" }] },
"viewsWelcome": [{ "view": "changeStats.biggest", "contents": "No changed files in any column." }]

A container is an icon in the Activity Bar that opens the side bar on its views, one section each. The views' items come from the extension's process (view/setItems); until it sends some, a view shows its welcome text. Showing a container starts its extension.

The extension's process

main names an executable inside the extension's folder, started in that folder. One that is not executable runs through the interpreter its file extension names: .py python3, .js node, .sh sh, .rb ruby, .pl perl, .lua lua. It starts on its activation event (* or onStartupFinished: when the app starts; onCommand:<id>, onView:<id>), when one of its commands runs, or when its container shows.

The app and the process exchange JSON-RPC 2.0 messages, one JSON object per line, over the process's stdin and stdout. What it writes to stderr goes to the app's log (RUST_LOG=info). A request has an id and is answered by a response with the same id; a notification has none.

The process runs as the user, like VS Code's extensions: install only extensions you trust. It never slows the app down: messages to it queue in a thread of their own (and are dropped, with a warning, if it stops reading), and its messages are read on another. If it exits, it is marked Crashed in the Extensions view, its status bar items, views and decorations go, and the notice bar says so; turning the extension off and on again starts it again. Everything it sends is checked: a message that is not one is logged, a method the app does not know is answered with an error.

From the app

initialize (request), first:

{"jsonrpc":"2.0","id":0,"method":"initialize","params":{
  "extensionId":"ecliptic-examples.change-stats",
  "extensionPath":"/home/me/.local/share/ecliptic/extensions/change-stats",
  "appVersion":"0.1.0",
  "settings":{"changeStats.largeChange":200,"changeStats.statusBar":true}}}

Answer it to start receiving anything else; the answer may subscribe to events at once:

{"jsonrpc":"2.0","id":0,"result":{"subscriptions":["repositories","selection"]}}

repositories/changed (after subscribing to repositories): every column's repository and changed files, at once and then at most every quarter second while they change.

{"jsonrpc":"2.0","method":"repositories/changed","params":{"active":0,"columns":[
  {"column":0,"repository":"/home/me/src/app","name":"app","branch":"main","ahead":0,"behind":2,"unseen":3,
   "files":[{"path":"src/main.rs","origPath":null,"staged":null,"unstaged":"M","added":12,"removed":3,"binary":false}]}]}}

selection/changed (after subscribing to selection): the active column and what is selected in it.

{"jsonrpc":"2.0","method":"selection/changed","params":{"column":0,"repository":"/home/me/src/app",
  "file":{"path":"src/main.rs","staged":false},"commit":null}}

configuration/changed: its settings, when one of them changed.

command/execute (request): one of its commands was run, with its arguments (a file's or a commit's from a menu, the active column's repository otherwise, a keybinding's args, a view item's arguments). Answer with a result, or with an error, which the notice bar shows.

{"jsonrpc":"2.0","id":3,"method":"command/execute","params":{"command":"changeStats.fileStats",
  "arguments":{"repository":"/home/me/src/app","path":"src/main.rs","status":"M","staged":false}}}

shutdown: before the app quits or turns the extension off; then its stdin closes. It should exit.

From the extension

Each of these may be sent as a notification, or as a request to hear whether it worked.

Method Params Does
subscribe { "events": ["repositories", "selection"] } Starts those events (the current state comes at once)
statusBar/set { "id", "text", "tooltip"?, "command"?, "alignment"?: "left" \| "right", "priority"? } Adds or changes a status bar item; text may start with $(icon); a click runs command
statusBar/remove { "id" } Takes it away
view/setItems { "view", "items": [Item] } A view's tree (one of its own views)
decorations/set { "repository", "items": [{ "path", "badge", "tooltip"?, "color"? }] } Its decorations on that repository's files: a badge of up to two characters on the Changes row, in a colour (a colour id, as VS Code's file decorations); the tooltip joins the row's. An empty list clears them
window/showMessage { "type": "info" \| "warning" \| "error", "message" } The notice bar, with the extension's name
drawer/show { "title", "subtitle"?, "lines": [string] } (or "text") Text in the diff drawer
clipboard/writeText { "text" } The clipboard
env/openExternal { "uri" } Opens an http, https, mailto or file link
columns/open { "repository" } Goes to the column showing the repository, or adds one
commands/execute { "command", "arguments"? } Runs any command, built-in or contributed

A view Item is { "id"?, "label", "description"?, "tooltip"?, "icon"?: "$(name)", "command"?: "<id>" | { "command", "arguments" }, "collapsibleState"?: "expanded" | "collapsed", "children"?: [Item] }. A click on an item with children folds it open or shut; on one without, runs its command.

A minimal extension

#!/usr/bin/env python3
import json, sys

def send(m):
    m["jsonrpc"] = "2.0"
    print(json.dumps(m), flush=True)

for line in sys.stdin:
    m = json.loads(line)
    if m.get("method") == "initialize":
        send({"id": m["id"], "result": {"subscriptions": ["repositories"]}})
    elif m.get("method") == "repositories/changed":
        n = sum(len(c["files"]) for c in m["params"]["columns"])
        send({"method": "statusBar/set", "params": {"id": "n", "text": f"$(files) {n}"}})
    elif m.get("method") == "shutdown":
        break

Built-in commands

Any extension may run these with commands/execute, bind keys to them, or put them in menus.

Command Title Keybinding
workbench.action.showCommands View: Show All Commands ctrl+shift+p
workbench.action.openSettings Preferences: Open Settings (UI) ctrl+,
workbench.action.openSettingsJson Preferences: Open User Settings (JSON)
workbench.action.selectTheme Preferences: Color Theme ctrl+k ctrl+t
ecliptic.toggleAutoDetectColorScheme Preferences: Toggle Following the Desktop's Theme
workbench.view.scm View: Show Source Control ctrl+shift+g
workbench.view.extensions View: Show Extensions ctrl+shift+x
workbench.action.toggleSidebarVisibility View: Toggle Primary Side Bar Visibility ctrl+b
workbench.action.toggleActivityBarVisibility View: Toggle Activity Bar Visibility
workbench.action.toggleStatusbarVisibility View: Toggle Status Bar Visibility
ecliptic.toggleTwoRows View: Toggle Two Rows
ecliptic.fitColumns View: Fit Columns to Window
ecliptic.expandAll View: Expand All Sections
ecliptic.collapseGraphs View: Collapse All Graphs
ecliptic.home View: Home
ecliptic.closeDiff View: Close Diff
ecliptic.refreshAll Source Control: Refresh All f5
ecliptic.markAllSeen Source Control: Mark All as Seen
ecliptic.newColumn Column: New Column ctrl+t
ecliptic.openRepository Column: Open Repository… ctrl+o
ecliptic.closeColumn Column: Close Column ctrl+w
ecliptic.refreshColumn Column: Refresh Column ctrl+r
ecliptic.moveColumnLeft Column: Move Left ctrl+shift+pageup
ecliptic.moveColumnRight Column: Move Right ctrl+shift+pagedown
ecliptic.moveColumnUp Column: Move Up ctrl+shift+up
ecliptic.moveColumnDown Column: Move Down ctrl+shift+down
workbench.action.showAboutDialog Help: About
workbench.action.quit File: Quit ctrl+q

Colour ids

What a theme can set, and the defaults (a dark theme's missing colours are Dark Modern's, a light theme's Light Modern's).

Colour id Dark Modern Light Modern
window.activeBorder #3C3C3C #CECECE
titleBar.activeBackground #181818 #F8F8F8
titleBar.activeForeground #CCCCCC #3B3B3B
titleBar.border #2B2B2B #E5E5E5
commandCenter.background #222222 #FFFFFF
commandCenter.activeBackground #2A2D2E #F2F2F2
commandCenter.border #FFFFFF1F #CECECE
ecliptic.titleBar.closeHoverBackground #E81123 #E81123
ecliptic.titleBar.closeActiveBackground #F1707A #F1707A
scrollbarSlider.background #79797966 #64646466
scrollbarSlider.hoverBackground #646464B3 #646464B3
scrollbarSlider.activeBackground #BFBFBF66 #00000099
input.background #313131 #FFFFFF
input.border #3C3C3C #CECECE
checkbox.background #313131 #F8F8F8
settings.rowHoverBackground #2A2D2E80 #F2F2F280
settings.modifiedItemIndicator #0C7D9D #66AFE0
activityBar.background #181818 #F8F8F8
activityBar.border #2B2B2B #E5E5E5
activityBar.foreground #D7D7D7 #1F1F1F
activityBar.inactiveForeground #868686 #616161
activityBar.activeBorder #0078D4 #005FB8
activityBarBadge.background #0078D4 #005FB8
activityBarBadge.foreground #FFFFFF #FFFFFF
menu.background #1F1F1F #FFFFFF
menu.border #454545 #CECECE
menu.foreground #CCCCCC #3B3B3B
menu.selectionBackground #0078D4 #005FB8
menu.selectionForeground #FFFFFF #FFFFFF
editor.background #1F1F1F #FFFFFF
sideBar.background #181818 #F8F8F8
statusBar.background #181818 #F8F8F8
statusBar.foreground #CCCCCC #3B3B3B
statusBar.border #2B2B2B #E5E5E5
statusBarItem.hoverBackground #FFFFFF1A #0000000F
ecliptic.statusBarItem.accentForeground #0078D4 #005FB8
editorHoverWidget.background #202020 #F8F8F8
editorHoverWidget.border #454545 #C8C8C8
sideBar.border #2B2B2B #E5E5E5
list.hoverBackground #2A2D2E #F2F2F2
list.inactiveSelectionBackground #37373D #E4E6F1
toolbar.hoverBackground #FFFFFF1A #0000000F
toolbar.activeBackground #FFFFFF26 #0000001A
ecliptic.modal.backdrop #00000080 #00000040
widget.shadow #000000AB #00000040
foreground #CCCCCC #3B3B3B
ecliptic.emphasisForeground #E7E7E7 #1F1F1F
descriptionForeground #9D9D9D #717171
icon.foreground #C5C5C5 #424242
editorLineNumber.foreground #6E7681 #6E7681
focusBorder #0078D4 #005FB8
button.foreground #FFFFFF #FFFFFF
editor.selectionBackground #0078D44D #005FB82E
ecliptic.flashBackground #0078D466 #005FB833
badge.background #616161 #CCCCCC
badge.foreground #FFFFFF #3B3B3B
diffEditor.insertedLineBackground #2EA04326 #2EA0432E
diffEditor.removedLineBackground #F8514926 #F851492E
ecliptic.diffHunk.background #0078D41A #005FB814
ecliptic.diffHunk.foreground #569CD6 #0451A5
ecliptic.diff.insertedForeground #81B88B #587C0C
ecliptic.diff.removedForeground #C74E39 #AD0707
inputValidation.errorBackground #5A1D1D #F2DEDE
errorForeground #E4676B #C72E0F
inputValidation.warningBackground #3A2F1A #FFF8C5
editorWarning.foreground #CCA700 #BF8803
gitDecoration.modifiedResourceForeground #E2C08D #895503
gitDecoration.addedResourceForeground #81B88B #587C0C
gitDecoration.deletedResourceForeground #C74E39 #AD0707
gitDecoration.untrackedResourceForeground #73C991 #007100
gitDecoration.conflictingResourceForeground #E4676B #AD0707
scmGraph.historyItemRefColor #59A4F9 #005FB8
scmGraph.foreground1 #FFB000 #B07A00
scmGraph.foreground2 #DC267F #C2185B
scmGraph.foreground3 #40B0A6 #00897B
scmGraph.foreground4 #B66DFF #8A3FD1
scmGraph.foreground5 #E06C3C #C8501F
ecliptic.refLabel.headBackground #2F6CB5 #005FB8
ecliptic.refLabel.branchBackground #2F6E5A #2E7D5B
ecliptic.refLabel.remoteBackground #6B4C8C #7B52AB
ecliptic.refLabel.tagBackground #4D4D4D #717171