Small scripts.
Real edits.

A plugin is a folder, a manifest, and a JavaScript file.

No build step, no toolchain, no compiled binary. Register a command and it appears in the Plugins menu; contribute a panel and it appears in the side panel or the right dock, drawn natively from a description you return; hand back generated text and it opens as a tab of its own.

Browse reviewed plugins
Availability

The plugin system ships in Studio 1.19.0. Use Get Plugins… for reviewed catalog releases, install a local bundle or ZIP directly, or build against API generation 12 with this guide.

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.json inside it, and the script that manifest names.
  • Standard ECMAScript. There is no DOM, no fetch and no require — a plugin reaches the editor through the linelark object and nothing else.
Both editions run plugins

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

  1. Choose Plugins ▸ Reveal Plugins Folder in Finder.
  2. Create a folder there called Uppercase.linelarkplugin.
  3. Add a manifest.json with at least an id and a name.
  4. Add main.js with the command below.
  5. Choose Plugins ▸ Reload Plugins. Your command appears under Plugins ▸ Uppercase.
main.jsJavaScript
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.

manifest.jsonJSON
{
  "id": "com.example.sortlines",
  "name": "Sort Lines",
  "version": "1.0.0",
  "apiVersion": 6,
  "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"
}
FieldDefaultMeaning
idRequired

Reverse-DNS identity everything keys off. Two installed plugins claiming one id is reported as an error rather than one quietly winning.

nameRequired

Shown in the Plugins menu and in Manage Plugins.

version"0"

Displayed beside the name. Any string.

apiVersion1

The API generation you wrote against. A plugin asking for a newer host is refused at load with a message saying so.

minimumAppVersion—

The Linelark release that first spoke that generation, as three numbers. It is what the catalog says out loud — “Needs Linelark 1.19.0” is something a reader can act on, where a generation number is not.

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, and manage remotes. Reading git needs no declaration.

terminal"none"

Set to "run" to ask to run commands in the terminal panel. The widest thing a manifest can ask for, so it is granted on its own switch.

commands"none"

Set to "query" to ask to run one command with no shell and no terminal and read what it printed. A key of its own, not a third value of terminal: only a query hands output back to the plugin, so it is declared and granted separately.

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.

RegistrationWhat it does
addCommand(id, title, fn)

Registers a menu command. Call it at load time.

addContextMenuItem({ … })

Adds an opt-in command to saved-tab and sidebar file/folder context menus.

apiVersion()

The host API generation, currently 12.

log(message)

Appends to this plugin’s log, shown in Manage Plugins. console.log is the same thing.

The document in frontWhat it does
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.

ReadingWhat it does
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.

SelectionWhat it does
selection()

The selected text.

selectionRange()

An object with location and length.

setSelection(loc, len)

Selects, scrolls it into view, and focuses the editor.

caretLine()

The line the caret is on, 1-based. What a panel that follows the caret should read rather than scanning the buffer.

WritingWhat it does
replaceSelection(s)

Replaces the selection.

replaceRange(loc, len, s)

Replaces a range.

setText(s)

Replaces the whole buffer.

insert(s)

Inserts at the caret.

Panels, previews and buffersWhat it does
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.

openPluginSettings()

Opens Manage Plugins with the calling plugin selected, including its permissions and Keychain-backed credentials.

openURL(url)

Opens an explicit absolute http or https link in the default browser.

openVirtual({ … })

Opens a read-only buffer of text you generated. Reopening one key replaces it in place.

openDiff({ … })

Opens a unified diff drawn as the two files it describes, side by side. Read-only.

folderRoot()

The folder holding the tab in front, or null. In a multi-root window it moves as the user clicks about.

workspaceFolders()

Every folder the window has open, so a plugin can name which project it means instead of guessing.

measureText(text, { … })

The size the editor will actually paint that text at — what makes laying out a diagram yourself possible.

Keeping and handing outWhat it does
storeGet(key)

A string this plugin stored before, or null. One JSON file per plugin, in Application Support.

storeSet(key, value)

Stores a string. Returns the refusal when it will not fit — 256 KB a value, 4 MB and 2,000 keys a plugin — rather than throwing.

storeRemove(key)

Forgets one key. storeKeys() lists what this plugin has stored.

copyToClipboard(text)

Puts text on the pasteboard, as any Copy command would.

exportFile({ name, text })

Offers a name and some text to a save panel. The panel is the consent, so there is no path argument; a cancel is an ordinary false.

chooseFolder({ message })

Asks the user to pick a folder and answers with its path, or "" if they picked none. Needs a click, and nothing else.

Scheduling and waitingWhat it does
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.

Network (Studio)What it does
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.

setSecret(name, value)

Files a credential your plugin obtained itself into a declared Keychain slot. Write-only.

clearSecret(name)

Removes one it stored. Signing out needs no permission at all.

importWebPage()

Opens a browser the user drives, and resolves with what they confirmed — { text, title, url, format } — or null if they cancelled. Needs a click, and no host declaration.

Running commands (Studio, declared and allowed)What it does
canRunInTerminal()

Whether the edition, the manifest and the user all permit it. Check it before offering a Run button.

runInTerminal({ … })

Types command lines at a shell in the terminal panel and shows it. name, folder, commands. Nothing comes back.

canRunQuery()

Whether the edition, the manifest and the user all permit a query. Ask it separately from canRunInTerminal(): two declarations, two switches.

runQuery({ … })

Runs one command with no shell and no terminal, as a promise resolving { status, stdout, stderr, timedOut, truncated }. command, arguments, folder, timeout.

Reading git (Studio)What it does
repoIsAvailable()

Whether the open folder is inside a git work tree.

repoSelectFolder(path)

Binds every later git call to one of this window’s folders. Only a folder the window already has can be chosen.

repoSelectedFolder()

The folder those calls are bound to, or null when they follow the tab in front.

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.

Changing git (Studio, declared and allowed)What it does
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.

repoDiscardAsync(paths)

Restores files from the index, throwing away uncommitted edits. Linelark asks the user first; untracked files are refused.

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.

repoAddRemoteAsync(name, url)

Adds a configured remote after the host validates both arguments.

repoSetRemoteURLAsync(name, url)

Changes one remote’s URL.

repoRemoveRemoteAsync(name)

Removes a remote after a Linelark-owned confirmation. Local files, branches and commits remain.

Contextual commands

Context-menu items are opt-in and appear under your plugin’s own submenu. A handler receives the exact saved tab or sidebar object that was clicked. File targets include path, name, text, language, isOpen, isDirty and isReadOnly; every target includes kind and location. Unopened files are decoded from disk, while open files provide their live buffer without selecting a background tab.

A file context-menu itemJavaScript
linelark.addContextMenuItem({
    id: "lint-file",
    title: "Lint This File",
    locations: ["tab", "sidebar"], // omit for both
    kinds: ["file"],               // "folder" is also available
    extensions: ["js", ".jsx"],   // omit for every extension
    handler: function (target) {
        // target is the object that was clicked, not necessarily the front tab.
        // An open file supplies its live buffer, including unsaved edits.
        console.log(target.path, target.text, target.language,
                    target.isOpen, target.isDirty, target.isReadOnly);
    }
});
Every write is one undoable edit

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.

Node typesJavaScript
{ type: "heading", text: "History" }
{ type: "text",    text: "Nothing to show", style: "primary" }
{ type: "rows",    rows:  [{ id, title, detail, symbol,
                              badge, badgeTint }] }
{ type: "tree",    items: [{ path: "a/b/c.swift", badge: "M",
                              badgeTint: "warning" }] }
{ type: "actions", actions: [{ id, title, symbol,
                               enabled, tint }] }
{ type: "button",  id, title, symbol, prominent, enabled }
{ type: "graph",   commits: [{ sha, parents, subject,
                               author, date, refs }] }
{ type: "field",   id, label, placeholder, value,
                   multiline, submit, enabled }
{ type: "section", id, title, collapsed,
                   children: [nodes] }

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.

Panel ids are namespaced for you

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.

A commit boxJavaScript
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.

The same API, the other edgeJavaScript
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.

An unused dock costs nothing

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.

Registering a previewJavaScript
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.

Block typesJavaScript
{ 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.
  • source is 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 -1 and 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 link is followed for http, https and mailto; 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.
What a preview costs is your parser

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.

Three of those nodes exist for the shape of a sidebar rather than for the data in it. actions is a row of icons, each naming itself in a tooltip, because a panel three hundred points wide runs out of vertical room long before it runs out of things to offer. button is a labelled control drawn as one — rows looks like a list and actions like a strip of icons, and neither reads as press this, which matters wherever one step gates another. section groups nodes under a title that folds away.

badgeTint and an action’s tint name a meaning — neutral, positive, warning, negative, info — never a colour, so the theme keeps the palette and a badge cannot end up invisible against somebody’s background.

All of these, and openDiff and repoDiscardAsync below, are generation 2 of the API. Declare "apiVersion": 2 if you use them: an older Linelark refuses to load the plugin and says why, where without the declaration it would load it and silently skip the nodes it did not recognise.

openDiff is the same tab with one difference: you hand it a unified diff — git’s own output, as repoDiffAsync returns it — and the editor draws the two files it describes side by side, aligned, instead of showing the patch. A patch rather than two texts because that is what a plugin can obtain: comparing a staged change means comparing against the index, and nothing in this API hands back that blob. What you see is the hunks and their context, with a gap row marking each stretch git did not send.

Opening a generated bufferJavaScript
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.

  • key is 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.
  • label disambiguates the tab. With one, the tab reads main.swift — HEAD. Two tabs called main.swift with 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/catch it. A buffer costs several times its text once it has layout, so an accidental dump of every commit is refused rather than absorbed.
The user can keep one

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.

Why this had to be built rather than inherited

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().

The shape of an asynchronous panelJavaScript
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.

Declaring what you needJSON
{
  "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.

Using itJavaScript
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.

Writing one is allowed; reading one is not

Generation 7 adds setSecret(name, value) and clearSecret(name). A plugin may file a credential it obtained itself — an OAuth token from a device-flow sign-in the user completed in their browser — into the Keychain slot its manifest declares, and drop it again on sign-out. Storing one needs the declaration and network consent; clearing one needs neither, so a plugin can always undo its own sign-in.

There is still nothing that reads a value back. That asymmetry is the point: a browser sign-in becomes possible without the token ever living in a file the plugin can re-read, and the plugin remains unable to leak a key it does not hold.

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.

A page the user brings you

Generation 11 adds importWebPage(), which is not a request at all and needs no declared host. It opens a browser the user drives: they type the address, watch the page load, choose what to keep — the article, a CSS selector, or just their selection — review the extraction, and press Import. Only then does anything reach your plugin, as { text, title, url, format }, with format being the Markdown or HTML they picked. A cancel resolves to null.

Importing a page into a virtual bufferJavaScript
linelark.addCommand("import", "Import a Web Page…", async function () {
    // The user types the address, watches it load, reviews the extraction and
    // presses Import. null is them changing their mind.
    const page = await linelark.importWebPage();
    if (!page) return;

    linelark.openVirtual({
        key:      page.url,
        title:    page.title,
        language: page.format === "markdown" ? "markdown" : "html",
        text:     page.text
    });
});

There is no bridge into that page: no shared cookies, no script your plugin can evaluate in it, no way to steer it. The window is ephemeral and stores nothing, one importer is open at a time, and the call has to come straight from a click — so no plugin can put a browser on screen from a timer. It is Studio-only, like the network it uses.

What a request has to pass

  • Exact hosts, no wildcards. api.example.com does not mean “anything ending in example.com”. evil.api.example.com is a different host and is refused.
  • https only. 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 location come 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.
Studio only

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

A repository panel, completeJavaScript
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.

Three diffsJavaScript
// 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.

manifest.jsonJSON
{
  "id": "com.example.gitpanel",
  "name": "Git Panel",
  "git": "write"
}
Stage, commit, pushJavaScript
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.

What is missing is the point

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.

Studio only

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 · Running

Running commands

A plugin cannot start a process. What it can do, from generation 10, is hand command lines to a shell in the terminal panel — which is then shown, so the thing it started is in front of the person who asked for it.

manifest.jsonJSON
{
  "id": "com.example.launcher",
  "name": "Launcher",
  "apiVersion": 10,
  "minimumAppVersion": "1.11.0",
  "terminal": "run"
}
Running an applicationJavaScript
linelark.addPanel({
    id: "launcher",
    title: "Launcher",
    symbol: "play.rectangle",
    side: "right",

    render: function () {
        // Say so rather than drawing a button that always throws.
        if (!linelark.canRunInTerminal()) {
            return [{ type: "text", style: "primary",
                      text: "Turn on “Allow running commands” in Manage Plugins." }];
        }
        return [{ type: "button", id: "run", title: "Run the dev server",
                  symbol: "play.fill", prominent: true }];
    },

    // Straight from the click: a timer, a draw, or anything after an await is refused.
    onSelect: function () {
        try {
            linelark.runInTerminal({
                name:     "Dev server",             // optional — what the pane is called
                folder:   "~/dev/app",              // optional — the folder in front by default
                commands: ["npm ci", "npm run dev"] // typed as one line, joined with ";"
            });
        } catch (e) {
            linelark.log(e.message);                // a refusal names what to fix
        }
    }
});

The terminal is the point, not a workaround. Nothing comes back: no exit status, no output, no promise. A plugin given a process of its own would need somewhere to put the output, a way to be stopped and a way to answer a password prompt — and the panel already is all three. What you gain is the typing; what the user keeps is a pane they can read, stop with ⌃C, and press ↑ in after your panel is closed.

  • It takes a click. The call is refused from a timer, from render(), and from anything after an await, which ends the gesture it checks for. A plugin that fetches first and runs afterwards needs a second button — which is also the honest shape of it.
  • The commands are typed as one line, joined with ;. So each runs whether or not the one before it worked, and npm ci && npm start written on one line is how to ask for the other thing. They are deliberately not typed one line at a time: a shell does not read the second line until the first command has finished, so the rest would sit in the terminal’s input buffer where that command can read them — sudo takes the next command as its password.
  • A command is one line of text. A newline, a tab or an escape inside one is refused rather than typed: a newline would be a command you did not say you were running, and a tab is a completion the shell tries to expand. Twenty commands, 4,096 characters each.
  • folder may be absolute, ~-relative, or relative to the folder in front — and must exist. That is checked before the shell sees it, because a cd that fails scrolls past in a pane nobody is watching and everything after it then runs somewhere else.
  • A shell is found again by plugin, name and folder. Running the same thing twice returns to the pane it used rather than opening another; two launchers in one project stay two panes. If the first is still running, the new lines go to that program’s input, exactly as typing them would.

Reading what a command printed

Typing at a shell is the right shape for doing something and the wrong one for asking. A panel has to know the schemes in a project, the targets in a Makefile or the scripts in a package.json before it can offer anything, and none of that can be discovered in a pane the plugin cannot read. Generation 12 adds runQuery: one command, no shell, no terminal, resolving with its exit status and both streams.

manifest.jsonJSON
{
  "id": "com.example.buildandrun",
  "name": "Build and Run",
  "apiVersion": 12,
  "minimumAppVersion": "1.15.0",
  "terminal": "run",
  "commands": "query"
}
Discovering before offeringJavaScript
linelark.addPanel({
    id: "build",
    title: "Build",
    symbol: "hammer",
    side: "right",

    render: function () {
        if (!linelark.canRunQuery()) {
            return [{ type: "text", style: "primary",
                      text: "Turn on “Allow running commands” in Manage Plugins." }];
        }
        return [{ type: "button", id: "discover", title: "Find the schemes" }];
    },

    // Both questions are asked inside the click: an await ends the gesture, so the
    // second of two sequential runQuery calls would be refused.
    onSelect: async function () {
        const [schemes, sims] = await Promise.all([
            linelark.runQuery({ command: "xcodebuild", arguments: ["-list", "-json"] }),
            linelark.runQuery({ command: "xcrun", arguments: ["simctl", "list", "-j"] })
        ]);

        // A command that failed is an answer, not an exception.
        if (schemes.status !== 0) {
            linelark.log(schemes.stderr || "xcodebuild said no");
            return;
        }
        linelark.storeSet("schemes", schemes.stdout);
        linelark.refreshPanels();
    }
});
  • It is its own declaration and its own switch. "commands": "query" is not a wider terminal, and canRunQuery() is not canRunInTerminal(). A panel that offers Build because it may query, or lists schemes because it may type, is offering something that will be refused at the click.
  • No shell, so no shell syntax. The command is an executable and an array of arguments — no pipes, no globbing, no redirection, nothing to quote. A bare name is looked up on your login PATH; anything containing a / must be absolute, because a relative path would resolve inside the user’s project.
  • A non-zero exit resolves. That xcodebuild said no is the answer your panel has to show, not an exception. Only a refusal rejects — the edition, the declaration, the permission, the missing click, a malformed argument.
  • An await ends the gesture. So the second of two sequentially awaited queries is refused. Ask them together with Promise.all, which is the shape discovery wants anyway; a chain that genuinely depends on an earlier answer is punctuated by the user choosing something, and a choice is a click.
  • Bounded. Four queries in flight per plugin, 256 arguments, a 10 s default timeout and 120 s maximum, and 1 MB of output — timedOut and truncated come back with the result rather than being left for you to infer.
The widest permission there is

A command can do anything the user can do, including reaching the network and changing the repository — so "terminal": "run" and "commands": "query" are each granted on their own switch in Manage Plugins rather than riding on either of the others or on each other, and canRunInTerminal() and canRunQuery() answer false until they are. Both are Studio-only: the sandboxed edition has no terminal panel and starts no processes, and a plugin cannot tell the two editions apart, so say both.

12 · 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.log when 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 apiVersion to 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.

13 · 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.

Licence checks are advisory, not locks

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.

14 · 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. What it can do to the terminal is hand a shell some commands to run — see running commands — never read what is in one.
  • 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 access, and no process of its own. 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. Writing to disk is exportFile, where the save panel is the consent and the user picks the path. Commands can be run, but never quietly and never as a subprocess the plugin holds: they are typed into a visible shell, only from a click, and only with "terminal": "run" granted. Git remains a specific capability with a fixed set of operations rather than a general escape hatch.
  • 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. repoLog is capped at 2000 commits and openVirtual at 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 call refreshPanels().
  • 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 can open a web link only from a click. openURL takes an absolute http or https URL and is honoured only while the click that asked for it is still on the stack — an await ends that, which is why a browser sign-in shows the code first and opens the page from a second button. Nothing else reaches the browser.
  • 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 do not transfer

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.

15 · Complete

Worked example

The examples live in their own repository — github.com/ccascio/Linelark-plugins — 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, and draws the Mermaid and PlantUML diagrams in them — four previews from one plugin, with the offset bookkeeping that keeps your place across the switch, and a diagram engine that lays itself out and hands Linelark the shapes to paint. Launcher is the applications a project is made of — a name, a folder and the lines that start each one, run in a terminal you can watch — and is the one to read for a panel that saves as you type. Scratch Notes is a right-dock panel of notes pinned to a line. Sort Lines shows the shape of a plugin that edits.

Sort Lines.linelarkplugin / main.jsJavaScript
// 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.

16 · 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 id and name are both present. A trailing comma is the usual culprit.
Another plugin already uses that id.
Two installed plugins claiming one id is 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.log and console.log, plus any error it let escape, capped at the most recent 200 lines.
Back to the documentation