01 · Requirements
What you need
A text editor and JavaScript. Plugins are evaluated by JavaScriptCore, the engine already in macOS, so there is nothing to install and nothing to compile. Each plugin runs in its own context with its own globals, so two plugins cannot see or overwrite each other’s state.
- A folder named
Something.linelarkplugin. - A
manifest.jsoninside it, and the script that manifest names. - Standard ECMAScript. There is no DOM, no
fetchand norequire— a plugin reaches the editor through thelinelarkobject and nothing else.
The sandboxed Mac App Store edition reads the same plugins folder inside its own container. Install Plugin… is Studio-only, so the App Store edition only ever runs scripts you placed there yourself.
02 · Quickstart
Your first plugin
- Choose Plugins ▸ Reveal Plugins Folder in Finder.
- Create a folder there called
Uppercase.linelarkplugin. - Add a
manifest.jsonwith at least anidand aname. - Add
main.jswith the command below. - Choose Plugins ▸ Reload Plugins. Your command appears under Plugins ▸ Uppercase.
linelark.addCommand("upper", "Uppercase Selection", function () {
linelark.replaceSelection(linelark.selection().toUpperCase());
});The script runs once at load. Its job is to register what the plugin offers; the functions you hand to addCommand run later, each time the user picks that command.
03 · Metadata
The manifest
Only id and name are required. Everything else has a default or is simply omitted.
{
"id": "com.example.sortlines",
"name": "Sort Lines",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js",
"author": "Your Name",
"description": "Sorts the selected lines, and drops duplicates on request.",
"license": "MIT",
"homepage": "https://example.com/sortlines",
"pricing": "free"
}idRequiredReverse-DNS identity everything keys off. Two installed plugins claiming one id is reported as an error rather than one quietly winning.
nameRequiredShown in the Plugins menu and in Manage Plugins.
version"0"Displayed beside the name. Any string.
apiVersion1The API generation you wrote against. A plugin asking for a newer host is refused at load with a message saying so.
main"main.js"The script to evaluate, relative to the plugin folder.
author—Credited in Manage Plugins.
description—One line describing what the plugin does.
license—An SPDX identifier by convention. Shown as written.
homepage—Where the plugin lives. http and https only.
pricing"free"One of free, paid, or trial. An unknown word reads as free rather than failing the manifest.
purchaseUrl—Where to buy or licence it. http and https only.
hosts[]The hosts this plugin may reach, exactly as written. No wildcards. Declaring one is a request; the user still has to allow it.
secrets[]Credentials you need, each naming the header it belongs in and the single declared host it may be sent to. You never receive the value.
git"none"Set to "write" to ask to change the repository — stage, commit, fetch, pull, push, switch branches. Reading git needs no declaration.
04 · Reference
The linelark API
Everything a plugin can reach hangs off one global object. Offsets are UTF-16 code units throughout — what the editor’s storage and JavaScript’s own strings both count in — so linelark.text().substr(loc, len) and linelark.getRange(loc, len) always agree, astral-plane characters included.
addCommand(id, title, fn)Registers a menu command. Call it at load time.
apiVersion()The host API generation, currently 1.
log(message)Appends to this plugin’s log, shown in Manage Plugins. console.log is the same thing.
fileName()The front tab’s display name, such as main.swift or new 1.
filePath()Its full path, or null for an untitled tab.
language()The current language id, such as swift.
isReadOnly()Whether the document refuses edits.
text()The whole buffer.
length()Its length in UTF-16 code units.
lineCount()Number of lines. A trailing newline closes the last line.
line(n)One line, 1-based, without its line break.
getRange(loc, len)A substring.
selection()The selected text.
selectionRange()An object with location and length.
setSelection(loc, len)Selects, scrolls it into view, and focuses the editor.
replaceSelection(s)Replaces the selection.
replaceRange(loc, len, s)Replaces a range.
setText(s)Replaces the whole buffer.
insert(s)Inserts at the caret.
addPanel({ … })Contributes a panel to the side panel or the right dock. Call it at load time.
addPreview({ … })Contributes a rendered view of a document, shown in place of its text. Call it at load time.
refreshPanels()Draws this plugin’s panels again.
openFile(path)Opens a file, absolute or relative to the open folder.
openVirtual({ … })Opens a read-only buffer of text you generated. Reopening one key replaces it in place.
folderRoot()The open folder, or null.
setTimeout(fn, ms)Runs fn later. Returns an id.
setInterval(fn, ms)Runs fn repeatedly. Clamped to 4ms; 64 timers per plugin.
clearTimeout(id)Cancels a pending timer. clearInterval is the same call.
queueMicrotask(fn)Runs fn at the end of the current call, before any timer.
fetch({ … })One https request to a declared host, as a promise. url, method, headers, body, timeout.
canReachNetwork()Whether the edition, the manifest and the user all permit a request.
hasSecret(name)Whether the user has supplied a declared credential. Never its value.
repoIsAvailable()Whether the open folder is inside a git work tree.
repoRoot()The work tree root — often a parent of the open folder.
repoHead()Current branch, or a short sha when the head is detached.
repoTracking()What the branch tracks and by how much: upstream, ahead, behind. null when it tracks nothing.
repoBranches()Local branches with their upstreams, and which one is checked out.
repoRemotes()Configured remotes with their URLs — how you work out which forge you are on.
repoLog(limit)Commits newest first with their parents, capped at 2000.
repoFiles()Tracked and untracked files with their porcelain state, and both columns separately.
repoShow(path)A tracked file’s committed text at HEAD, or null. Pair it with openVirtual to show a revision.
repoDiffAsync({ … })git’s own unified diff, as a promise. path, staged, untracked.
repoLogAsync(limit)The same as repoLog, off the main thread, as a promise.
repoFilesAsync()The same as repoFiles, off the main thread, as a promise.
repoShowAsync(path)The same as repoShow, off the main thread, as a promise.
repoCanWrite()Whether the edition, the manifest and the user all permit a change. Check it before offering buttons.
repoStageAsync(paths)Stages the given repository-relative paths.
repoUnstageAsync(paths)Unstages them. The change returns to the working tree; it is never discarded.
repoCommitAsync(message)Commits what is staged. The message goes to git on stdin, never as an argument.
repoFetchAsync()Fetches from the remote.
repoPullAsync()Pulls, fast-forward only. A pull that would have to merge is refused and reported.
repoPushAsync()Pushes the current branch, setting an upstream when it has none. Never a force.
repoSwitchAsync(branch)Switches branches. git refuses when it would overwrite uncommitted work.
repoCreateBranchAsync(branch)Creates a branch and switches to it.
It is named after the command that made it and reaches every pane showing that buffer, exactly as a typed edit does. You never have to batch changes yourself for undo to behave.
05 · Interface
Panels and the docks
A plugin can contribute its own panel to the side panel’s switcher — an extra icon beside Folder, Open Files, Function List and Search Results. symbol is an SF Symbol name; one that does not resolve falls back to a placeholder rather than drawing an invisible button.
{ type: "heading", text: "History" }
{ type: "text", text: "Nothing to show", style: "primary" }
{ type: "rows", rows: [{ id, title, detail, symbol, badge }] }
{ type: "tree", items: [{ path: "a/b/c.swift", badge: "M" }] }
{ type: "graph", commits: [{ sha, parents, subject,
author, date, refs }] }
{ type: "field", id, label, placeholder, value,
multiline, submit, enabled }Your render() returns an array of these and Linelark draws them with the same SwiftUI the built-in panels use. That is the bargain: a plugin gets a real native panel, in the right theme, in both appearances — and cannot paint arbitrary pixels or wedge the sidebar in a layout pass of its own.
- Text is secondary by default.
style: "primary"is the opt-in, for the one line in a panel that should carry weight — most of what a panel says is commentary and should look like it. - A tree takes flat
/-separated paths and builds the folders itself, so a plugin listing a repository never nests anything by hand. Clicking a file opens it. - A graph lays out lanes from the parent links, merges included. You supply commits; the routing is ours.
- An unrecognised node is skipped rather than failing the panel, and a
render()that throws becomes content that says so — not an alert on every frame.
render() is called when the panel appears, when the open folder or front tab changes, and whenever you call refreshPanels(). It is not called on every frame, so a panel may do real work — within reason; see the limits below.
Two plugins may both call a panel repository. Linelark keys the switcher on pluginId/panelId, so they never collide.
Responding to a click
Add an onSelect function to the descriptor and it is called with the id of whatever the user clicked — a row’s id, or a file’s path from a tree. It is optional, and leaving it out is a real choice rather than an omission: a panel with no handler still opens the files in its tree, which is what a tree of paths means without being told.
Handle an id you recognise and return; anything you do not handle falls through to that default. There is no way to mark a row as unclickable — decide inside the handler.
Taking something back
A field is the one node that sends something to the plugin: a box and the button that submits it, delivered to the panel’s onSubmit(id, value). It exists because a panel that can commit is useless without somewhere to write a commit message, and there is no other way for a plugin to ask for text.
linelark.addPanel({
id: "repository",
title: "Repository",
symbol: "arrow.triangle.branch",
render: function () {
return [
{ type: "heading", text: "Commit" },
{ type: "field", id: "message", placeholder: "Message",
value: draft, multiline: true, submit: "Commit",
enabled: stagedCount > 0 }
];
},
// Called with the field's id and whatever was typed in it.
onSubmit: async function (id, value) {
draft = value; // keep it, in case the commit fails
var result = await linelark.repoCommitAsync(value);
if (result.ok) { draft = ""; } // cleared only once git took it
linelark.refreshPanels();
}
});You supply the initial value and the panel owns what is typed after that. A panel redraws whenever the workspace changes, and a value owned by the plugin would take a half-written sentence away mid-thought — so a new value is adopted only when it differs from the last one the panel saw. That is how a box clears itself after a successful commit and keeps the message after a failed one.
Set enabled: false when the action cannot be taken — nothing staged, work already running — and the button dims rather than throwing when it is pressed. multiline gives a small text area; ⌘↩ submits it, because Return belongs to the text.
The right dock
Declare side: "right" and the panel goes to the dock on the other edge instead, opened with View ▸ Show Right Dock (⌥⌘B). That is the only difference; everything else about the API is identical, and the dock has its own switcher showing one icon per panel.
linelark.addPanel({
id: "notes",
title: "Notes",
symbol: "text.badge.checkmark",
side: "right",
render: function () { … }
});The two edges are for different things. The side panel describes the project — a file tree, a repository, search results. The right dock is for what sits beside the work: an outline, test results, an assistant. Put a panel where its content belongs rather than where there is room.
The right dock is not built at all unless some plugin provides a right-side panel and the dock is open. With no such plugin installed there is no column, no switcher, and nothing in memory — an empty column is worse than no column.
06 · Rendering
Rendering a document
A panel describes a project. A preview renders the document itself: register one for the file types you can render, and Linelark puts a Preview button in the toolbar (⇧⌘V, also View ▸ Preview) that swaps the pane between the text and your rendering.
linelark.addPreview({
id: "markdown",
title: "Markdown", // the button reads "Preview as Markdown"
extensions: ["md", "markdown"], // which files this claims…
languages: ["markdown"], // …and which languages, for untitled buffers
// Called with the whole document. Never async.
render: function (text) {
return [
{ type: "heading", level: 1, source: 0,
spans: [{ text: "Title" }] },
{ type: "paragraph", source: 8,
spans: [{ text: "Prose with " },
{ text: "bold", bold: true }] },
{ type: "code", text: "let x = 1", language: "swift", source: 40 }
];
}
});You return blocks and Linelark draws every pixel of them — the same bargain a panel strikes, and for the same reasons. It is also why a preview cannot run script, load a remote stylesheet, or look out of place in a theme: there is no HTML document anywhere in this, not even for an HTML preview.
{ type: "heading", level, spans, source }
{ type: "paragraph", spans, source }
{ type: "code", text, language, source }
{ type: "quote", children: [ …nodes ], source }
{ type: "list", ordered, start, source,
items: [{ spans, checked, children }] }
{ type: "table", headers: [spans], rows: [[spans]], source }
{ type: "rule", source }
// A span carries text and any combination of marks:
{ text: "hello", bold, italic, code, strike, link }- Which documents you claim is declared, not asked. The toolbar reads it on every redraw, so calling into your plugin there would run a script on every keystroke. A descriptor naming neither an extension nor a language is refused at load, because it could never apply to anything and you would have nothing on screen to explain why.
sourceis what keeps the reader’s place. It is the offset in the document the block came from. Switching to the preview scrolls to the block that owns the line you were on; switching back scrolls to the line the block on screen came from. A block you cannot place — nested content lexed from text whose markers were stripped — says-1and is simply never scrolled to, which is better than a confident guess at the wrong line.- Marks combine. Bold inside a link inside emphasis is one span with three things true of it, not three nested spans. A
linkis followed forhttp,httpsandmailto; anything else is treated as a path, resolved against the file being previewed and opened as a tab. render(text)is never async. It is handed the whole document and must return blocks. One that returns a promise is reported as that mistake rather than drawn as an empty pane.- The state belongs to the pane, not to you. There is no call to open, close, or ask about a preview — the user presses the button. The same file can be source in one split and rendered in the other, and your plugin is not told either way.
It is re-rendered whole each time the text settles, and JavaScriptCore interprets your code. The bundled example measures about 2 ms per KB for Markdown through marked, against 0.05 ms per KB for a hand-written HTML parser. Refuse documents past a size you have actually measured and say so in the preview — there is no watchdog, so the limit has to be yours.
Registering more than one is normal. The title is what the button says, so a plugin covering two formats should call addPreview twice — a .md file then offers “Preview as Markdown” and a .html file “Preview as HTML”, rather than one button that means both. If two plugins claim the same extension, the first loaded wins.
07 · Content
Buffers you generate
A panel shows a summary. Sometimes what a plugin has to show is a document — an old revision, a formatted dump, a rendered report — and that belongs in the editor, not in a sidebar. openVirtual opens text you generated as a tab of its own.
linelark.openVirtual({
key: "HEAD:src/main.swift", // required — identity, not a path
name: "main.swift", // required — the tab's name
text: committedText, // required — up to 4 MB
label: "HEAD", // optional — tab reads "main.swift — HEAD"
language: "swift" // optional — defaults to plain text
});What comes back is an ordinary Linelark buffer. It gets syntax highlighting, the find bar, the document map, bookmarks and split panes for free, because it is the same kind of object every other tab is. It is read-only, and it arrives clean, so it never asks to be saved.
keyis identity, not a path. Opening the same key again replaces that buffer’s text in place rather than stacking up a tab per click — which is what makes clicking through a file tree safe. Keys are namespaced per plugin, so two plugins cannot collide.labeldisambiguates the tab. With one, the tab readsmain.swift — HEAD. Two tabs calledmain.swiftwith no other difference are a puzzle.- Nothing is persisted. A generated buffer is never written to the session store and never restored at launch. It has no file behind it, and an empty tab claiming to be a revision would be worse than no tab.
- Text is capped at 4 MB. Over that, the call throws and you can
try/catchit. A buffer costs several times its text once it has layout, so an accidental dump of every commit is refused rather than absorbed.
Saving a generated buffer to a file makes it the user’s own: it stops being generated content and becomes an editable file like any other. You do not have to do anything to allow this — but do not rely on a buffer you opened staying read-only forever.
This is the general mechanism behind the revision view below, and it is deliberately not specific to git: a Markdown preview, a formatted JSON dump, a decompiled listing and a diff are all the same shape — text a plugin produced, shown where text belongs.
08 · Time
Timers and waiting
Plugins have an event loop: setTimeout, setInterval, clearTimeout, clearInterval and queueMicrotask, plus promises you can await. That is less ordinary than it sounds — a bare JavaScriptCore context has none of it.
JavaScriptCore ships no event loop. There is no setTimeout, no fetch, and no I/O source of any kind. Promise exists and its microtasks drain, but nothing can ever settle one except the host reaching in — so a promise you created and did not resolve yourself stays pending forever. Every capability below is provided by Linelark, not by the engine.
The practical consequence is that a plugin can now do something after the call that started it has returned. Split a long scan into slices and the editor stays responsive between them; debounce a redraw; await a slow read instead of freezing the window while it runs.
Await, then redraw
render() is never asynchronous, and returning a promise from it is reported as the mistake it is rather than drawn as an empty panel. A panel shows what is known now. Do the waiting in a command, a click handler or a timer, keep what it produced, and call refreshPanels().
linelark.addPanel({
id: "repository",
title: "Repository",
symbol: "arrow.triangle.branch",
// render() is never async. It draws what is known now.
render: function () {
return [{ type: "rows", rows: rows }];
},
onSelect: async function (path) {
var committed = await linelark.repoShowAsync(path); // off the main thread
linelark.openVirtual({ key: "HEAD:" + path, name: path,
label: "HEAD", text: committed });
rows = describe(committed);
linelark.refreshPanels(); // now draw it
}
});Errors after an await
An async function returns a promise the moment it first awaits, so the call that started it has already succeeded and anything you throw afterwards becomes a rejection rather than an error. Linelark catches those and records them in your plugin’s log — but nothing can put them back in front of the user as a failed command, because by then there is no command left to fail. Catch what you can handle where you can still act on it.
What is clamped
- Repeating timers are clamped to 4 ms.
setInterval(fn, 0)is a spin loop on the one thread the editor draws on, so it is not offered. - Sixty-four live timers per plugin. Past that, scheduling is refused and the refusal goes to your log rather than being swallowed — a timer that silently never fires reads as a broken runtime.
- Unloading a plugin stops its clock. Disable or reload one and every pending timer is cancelled and every promise still in flight is abandoned.
What this does not add is a way to interrupt a plugin. Waiting is now interruptible; computing is not, so a script that never yields still hangs the editor — see the limits below.
09 · Reach
Network and credentials
A plugin does not get a general fetch. It gets an HTTPS request to a host it named in its own manifest, that the user switched on, carrying a credential it has never seen.
{
"id": "com.example.assistant",
"name": "Assistant",
"hosts": ["api.anthropic.com"],
"secrets": [
{
"name": "apiKey",
"label": "Anthropic API key",
"header": "x-api-key",
"host": "api.anthropic.com"
}
]
}Both halves are declarations, not permissions. The user sees the host list in Manage Plugins, switches network on, and pastes the key — and until they do, every request is refused with an error saying so.
linelark.addCommand("ask", "Ask", async function () {
if (!linelark.canReachNetwork()) {
linelark.log("Turn on network access in Manage Plugins.");
return;
}
if (!linelark.hasSecret("apiKey")) {
linelark.log("Add your API key in Manage Plugins.");
return;
}
// No Authorization header here: the host attaches the key, because the
// manifest tied it to this host. The plugin never holds the value.
var response = await linelark.fetch({
url: "https://api.anthropic.com/v1/messages",
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "claude-opus-5", max_tokens: 256,
messages: [{ role: "user", content: linelark.selection() }] })
});
if (!response.ok) {
linelark.log("HTTP " + response.status);
return;
}
linelark.insert(JSON.parse(response.body).content[0].text);
});The credential never reaches your code
There is no call that returns a secret. hasSecret(name) answers a boolean and nothing more — enough to prompt for a key that is missing, and nothing at all about one that is present. The host attaches the value to requests bound for the host the manifest tied it to, applying the header last, after any header of the same name you set has been dropped.
Declare several for one host if you need them — each in its own header, and all of them are attached. A manifest is refused, with the reason shown in Manage Plugins, if two credentials share a name, if two compete for the same host and header, or if one names a host the plugin did not declare. Each of those is a credential that could never be sent, and accepting it quietly would leave you with a key the manager shows and nothing ever uses.
This costs you something real, and it is worth knowing before you design around it: there is no request signing. A scheme that needs the secret to compute an HMAC, or to sit in a request body, cannot be done — header and bearer auth only. In exchange, a plugin cannot leak a key it never held, and one that stops being trusted cannot take the key with it.
What a request has to pass
- Exact hosts, no wildcards.
api.example.comdoes not mean “anything ending in example.com”.evil.api.example.comis a different host and is refused. httpsonly. Plain HTTP is refused even for a declared host — a credential the host attaches would otherwise go out in clear text.- Redirects are not followed. The 3xx and its
locationcome back to you; ask again and the new URL goes through the allowlist like any other request. - No cookies, ever. The session is ephemeral and stores none, so requests are as unconnected to each other as your context is to another plugin’s.
- Bounded. Six requests in flight per plugin, 5 MB per response, 30 s default timeout and 120 s maximum. Transport headers —
Host,Content-Length,Cookie— cannot be set. - Text, not bytes. A response body arrives as a string, so there is no fetching an image or any other binary.
The Mac App Store edition ships without the network entitlement, so canReachNetwork() answers false there and a request is refused rather than left to time out. Check it and say so, the same way you would for git.
What an update does to consent
Permission is recorded against what you asked for, not against your plugin’s id. Ship a version that adds a host, or moves a credential to another header, and the user’s previous “yes” no longer matches — they are asked again. A version bump, a new description or a code change on its own costs nothing.
Stored credentials are bound the same way. A value the user supplied for one host and header is not found under a different one, so an update cannot inherit a key it was not given. Plan for both: check hasSecret rather than assuming a key survived your upgrade.
Consent is read at the moment of each request, not when your plugin loads — so a user who switches it off stops a plugin that is already running. Uninstalling one drops its consent and every credential it was ever given, including credentials an earlier version declared and yours no longer does — as does deleting the folder by hand, or shipping a replacement whose manifest changes the plugin’s id.
10 · Git
Git
Plugins can read the git repository behind the open folder — the branch, what it tracks, the changed files, the commit history with parent links ready to lay out as a graph, any tracked file’s committed text, and git’s own diffs. No token, no network, no configuration: it is the repository already on disk. A plugin that declares it and is allowed it can also change that repository.
Reading
linelark.addPanel({
id: "repository",
title: "Repository",
symbol: "arrow.triangle.branch",
render: function () {
if (!linelark.repoIsAvailable()) {
return [
{ type: "heading", text: "No repository" },
{ type: "text", text: "Open a folder inside a git work tree." }
];
}
return [
{ type: "rows", rows: [
{ id: "branch", title: linelark.repoHead(),
detail: linelark.repoRoot(),
symbol: "arrow.triangle.branch" }
]},
{ type: "heading", text: "Files" },
{ type: "tree", items: linelark.repoFiles().map(function (file) {
return { path: file.path, badge: file.state || null };
})},
{ type: "heading", text: "History" },
{ type: "graph", commits: linelark.repoLog(120) }
];
},
// Called with the id of whatever was clicked: a row's id, or a file's path.
onSelect: function (id) {
if (id === "branch") {
linelark.refreshPanels();
return;
}
var committed = linelark.repoShow(id);
if (committed === null) {
linelark.openFile(id); // untracked, or nothing to compare against
return;
}
// The working copy first, so the editor lands on the file being worked on;
// the committed text opens beside it, read-only.
linelark.openFile(id);
linelark.openVirtual({
key: "HEAD:" + id,
name: id.split("/").pop(),
label: "HEAD",
text: committed,
language: linelark.language()
});
}
});Reading needs no declaration and no permission. It describes the folder the user opened, at the moment they opened it.
repoFiles() gives each file a merged state for a badge, and both porcelain columns separately as index and worktree, with staged, unstaged and untracked already worked out. That distinction is the difference between a list of changes and a client: a file can be staged and modified again, and one field cannot say so.
Comparing uncommitted with committed
repoDiffAsync is git’s own unified diff rather than one computed in JavaScript — which is what keeps it agreeing with the command line the first time a rename or a whitespace option comes up. Open it as a generated buffer with language: "diff" and it arrives highlighted, in the split the user already has.
// The working tree against the index, for one file.
var diff = await linelark.repoDiffAsync({ path: "src/main.swift" });
// The index against HEAD — what a commit would actually contain.
var staged = await linelark.repoDiffAsync({ path: null, staged: true });
// A file git has never seen has no diff at all; ask for it as an addition.
var added = await linelark.repoDiffAsync({ path: "new.txt", untracked: true });
linelark.openVirtual({
key: "diff:src/main.swift", name: "main.swift.diff",
label: "changes", text: diff, language: "diff"
});An untracked file is the case worth remembering: git diff says nothing at all about a file it has never seen, so a panel that showed an empty diff for a new file would look broken. Ask for it with untracked: true and it comes back as a whole-file addition.
repoShow(path) is the other half — the committed text at HEAD, ready for openVirtual so the old version opens beside the working copy. It answers null for anything git does not have there, which is a case to fall back on rather than an error.
Changing the repository
Staging, committing, fetching, pulling, pushing and switching branches are gated twice. Your manifest declares the request; the user grants it in Manage Plugins, in its own switch separate from network access — a plugin that commits for you need not also reach the internet.
{
"id": "com.example.gitpanel",
"name": "Git Panel",
"git": "write"
}linelark.addCommand("commitAll", "Stage and Commit", async function () {
if (!linelark.repoCanWrite()) {
linelark.log("Allow changes to git for this plugin in Manage Plugins.");
return;
}
var changed = linelark.repoFiles()
.filter(function (file) { return file.unstaged || file.untracked; })
.map(function (file) { return file.path; });
if (!changed.length) { return; }
await linelark.repoStageAsync(changed);
// Every write answers { ok, output }, where output is git's own words —
// which is the half worth showing when something goes wrong.
var committed = await linelark.repoCommitAsync("Work in progress");
if (!committed.ok) {
linelark.log(committed.output);
return;
}
var pushed = await linelark.repoPushAsync();
linelark.log(pushed.ok ? "Pushed." : pushed.output);
});Every write is asynchronous and answers { ok, output }, where output is git’s own message. Show it. “Push failed” is a sentence nobody can act on; “Updates were rejected because the remote contains work that you do not have locally” is one they can.
Both gates are checked at the moment of each call, not at load — so a permission taken away stops the next commit rather than the next launch. A call you are not allowed to make comes back as a rejected promise naming what to switch on, so it lands in the same catch as a failure from git.
There is no force push, no reset, no clean, no discard and no merge anywhere in the API, and repoPullAsync is fast-forward only. Nothing a plugin can do will destroy uncommitted work or overwrite commits somebody else pushed; a pull that would have to merge is reported rather than started, because a sidebar is the wrong place to be in a conflicted merge. This is not a rule to follow — the calls do not exist.
Reads are built inside Linelark from fixed argument lists, and the only thing a plugin influences is a clamped integer. Writes cannot be, since a message, a branch and a path are the point of them — so instead: there is no shell, which removes quoting as a category of problem; paths and refs are refused if they are empty, begin with -, are absolute or walk upwards; refs are held to git’s own naming rules; paths are passed after --; and a commit message goes in on standard input rather than as an argument, because it is the one genuinely free-form string in the set.
The editor never prompts for credentials. Push and pull run with terminal prompting disabled, so a repository that needs a password fails with git’s own message instead of hanging the app on a prompt no window is showing. Tell your user to authenticate once in the terminal; the credential helper answers silently afterwards.
The sandbox blocks subprocesses outright, so the Mac App Store edition does not ship the git reader at all: repoIsAvailable() and repoCanWrite() answer false there and repoShow answers null. Check them and show an explanation — a panel that comes up empty reads as broken. Panels, previews, clicks and generated buffers all work in both editions; only git is Studio-only.
This is deliberately the local half of a forge integration. A file tree, a commit graph and a diff are facts about the checkout, so fetching them from an API would add a credential, a permission prompt and an offline failure mode to something that already works without any of them. Pull requests, issues and Actions are the part that genuinely needs the network — and that is what the network capability above is for.
11 · Craft
Best practices
- Make one edit, not many.
- Compute the whole replacement and apply it with a single
replaceRange. A loop of small edits produces a stack of undo steps the user has to unwind one at a time, and every edit invalidates offsets computed before it. - Read offsets immediately before you use them.
- Any write moves everything after it. If you need to edit in several places, work from the end of the document backwards, or recompute after each change.
- Handle an empty selection.
- Commands are always enabled while a document is open. Decide deliberately whether yours acts on the selection, the current line, or the whole buffer, and say so with
linelark.logwhen there is nothing to do. - Let errors be errors.
- A range outside the document, a write to a read-only file, or a call with no document open throws a real JavaScript error you can
try/catch. An error you do not catch is reported to the user with your plugin’s name and recorded in your log — it never takes the editor down. - Namespace nothing, but assume nothing either.
- Your globals are yours alone, so short names are safe. For the same reason you cannot rely on another plugin having loaded.
- Keep commands fast.
- They run on the main thread while the user waits. Work proportional to the document is fine; unbounded loops are not.
- Version the API you need.
- Set
apiVersionto the generation you tested against. A plugin asking for a newer host is refused with an explanation instead of failing halfway through its first command.
12 · Distribution
Free and paid plugins
Plugins may be free or commercial. Authors sell their own work, on their own terms, from their own site.
Linelark is not a payment store. Its reviewed catalog records immutable releases, but Linelark takes no payment and enforces no licence. A commercial plugin is bought from its author; the manifest simply carries the sign that says what it costs and where to get it. Declare pricing as free, paid, or trial, and add a purchaseUrl. Manage Plugins shows the label and links out to you.
A plugin is plain JavaScript in a folder the user owns and can read. Any check inside one can be removed by anyone who cares to. Price on trust and convenience, as independent tools have done for years, and do not spend effort on protection the format cannot provide.
Two consequences worth knowing before you charge for one. Only http and https links are honoured, because a manifest is untrusted text that ends up behind something the user clicks. And a pricing word this version of Linelark does not recognise reads as free rather than failing the manifest, so a plugin never stops loading because a newer word appeared.
13 · Boundaries
What plugins cannot do
Stated plainly so you do not design around something that is not there.
- Panels and previews are declarative. A plugin picks from the node types that exist and cannot draw its own. It can react to a click and take text from a
field, but there is no webview, no canvas, no image, and no way to add a sheet, a context menu, or a keyboard shortcut. The one toolbar item a plugin can reach is the Preview button, and only by registering a preview. - Editing reaches the front document only. A plugin can open a tab — a file, or a buffer it generated — but it cannot enumerate or close tabs, and there is no API for the find state or the terminal.
- Generated buffers are read-only and unsaved. A plugin writes one when it opens it and cannot write to it afterwards; to change what it shows, open the same key again with new text.
- No arbitrary disk or process access. The context has no file system and cannot run a command. Git is a specific capability with a fixed set of operations — not a general escape hatch — and Studio-only. There is no call that reads a file: a plugin can only read the front document, so reading another one means opening it as a tab first — or asking git for its committed text.
- Git cannot destroy work. Force, reset, clean, discard and merge are absent from the API and pull is fast-forward only. Changing anything at all needs
"git": "write"in the manifest and the user’s consent, and no history rewriting is reachable — no amend, no rebase, no tag, no stash, no per-hunk staging. - Git writing has no progress and no cancel. A push is one call that answers when it is done, or when two minutes have passed. A very large push looks like a stalled panel; say what you are doing before you await.
- Network is Studio-only, allowlisted, and https. There is no general
fetch: only the hosts your manifest names and the user allows. No request signing, no binary responses, no redirects followed, no cookies. - Every call into a plugin is on the main thread. A plugin can leave it, by awaiting work the host runs elsewhere, but it never re-enters on another one — so a command or a
render()should stay proportional to the repository rather than unbounded.repoLogis capped at 2000 commits andopenVirtualat 4 MB for the same reason. - Panels and previews never await.
render()has to return nodes, not a promise. Await elsewhere, keep what it produced, and callrefreshPanels(). - Commands, panels and previews are fixed at load. A plugin registers everything it offers when it loads, then decides what to do inside them — it cannot vary its menu by language or file. Which documents a preview claims is likewise declared once.
- A plugin cannot open a URL. There is no call that reaches the browser. A link inside a preview is followed when the reader clicks it, which is the only route out.
- Nothing watches the filesystem. A panel redraws when it appears, when the folder or front tab changes, and on request. A commit made in a terminal needs the panel's refresh button.
- No watchdog. A plugin cannot be interrupted, so one that loops forever still hangs the editor. The event loop does not change this: it lets a plugin stop waiting, not stop computing. A plugin that throws is caught and reported; only one that never yields is a problem.
- No native code. Plugins cannot link libraries, which is why capabilities needing one belong in the editor rather than in a plugin.
Notepad++ plugins are compiled libraries that drive Scintilla through its message interface. Linelark has no Scintilla and loads no binaries, so those plugins cannot run here in any form. See the migration guide.
14 · Complete
Worked example
Four complete examples live in their own repository, written to be read and copied. GitHub and Document Preview are reviewed catalog releases; Scratch Notes and Sort Lines remain small source examples. None is bundled with the editor, so each can be revised and installed without waiting for an application release.
GitHub is a git client: staged and unstaged sections, the diff of anything changed, a commit box, branches, and pull requests from the API. It is the one to read for what a real panel looks like, and the only one that asks for permissions. Document Preview renders Markdown and HTML in place of their source — two previews from one plugin, with the offset bookkeeping that keeps your place across the switch. Scratch Notes is a right-dock panel that lists TODO markers. Sort Lines shows the shape of a plugin that edits.
// Whatever lines the selection touches, whole — selecting half of a line
// should still sort that line rather than cutting it in two.
function selectedLineRange() {
var range = linelark.selectionRange();
var text = linelark.text();
var start = range.location;
while (start > 0 && text.charAt(start - 1) !== "\n") {
start--;
}
var end = range.location + range.length;
while (end < text.length && text.charAt(end) !== "\n") {
end++;
}
return { location: start, length: end - start };
}
function sortSelectedLines(unique) {
var range = selectedLineRange();
if (range.length === 0) {
linelark.log("Select the lines to sort first.");
return;
}
var lines = linelark.getRange(range.location, range.length).split("\n");
lines.sort(function (a, b) {
return a.localeCompare(b);
});
if (unique) {
lines = lines.filter(function (line, index) {
return index === 0 || line !== lines[index - 1];
});
}
// One replacement rather than one per line: it lands as a single undoable
// edit, named after the command in the Undo menu.
linelark.replaceRange(range.location, range.length, lines.join("\n"));
}
linelark.addCommand("sort", "Sort Selected Lines", function () {
sortSelectedLines(false);
});
linelark.addCommand("sortUnique", "Sort Selected Lines and Remove Duplicates", function () {
sortSelectedLines(true);
});Pair it with the manifest above, drop the folder in the plugins directory, and choose Plugins ▸ Reload Plugins.
15 · Resolve
Troubleshooting
- My plugin is not in the menu.
- Open Plugins ▸ Manage Plugins…. A plugin that failed to load is still listed, with the reason next to it. A plugin that loaded but registered nothing says so too.
- It says the manifest could not be read.
- Check the JSON is valid and that
idandnameare both present. A trailing comma is the usual culprit. - Another plugin already uses that id.
- Two installed plugins claiming one
idis an error rather than one silently winning. Change yours and reload. - My edit was refused.
- The document is read-only, or the range you gave falls outside it. Offsets are stale after any write — recompute them.
- How do I see what my plugin printed?
- Select the plugin in Manage Plugins. Its log holds everything from
linelark.logandconsole.log, plus any error it let escape, capped at the most recent 200 lines.
