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
"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 |