Files
orca/src/shared/plugins/plugin-list-entry.ts
T
NeilandClaude 5846caf2ba plugins: editor.save.before hook (format on save, or refuse the save) (#37347)
* plugins: editor.save.before hook (format on save, or refuse the save)

Explicit saves of text files from an editor tab run plugins' editor.save.before
in main before the write (local, SSH, WSL and folder workspaces all save through
that one renderer path). A plugin can return changed text or deny with a reason,
shown in the save error. The buffer takes the hook's text only if it is unchanged
since the hook started; edits made meanwhile stay dirty. Autosave, restart saves
and bytes documents are not hooked, and with no live plugin listing the hook the
save path adds no await.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(plugins): editor.save.before text reaches the visible Monaco model

The hook writes its text into the tab's draft, which reaches the model through
the same content-sync bridge as Reload from disk. A model-level test covers the
formatted text showing, the next keystroke editing it, and edits typed during the
hook staying in the model.

Co-Authored-By: Claude <noreply@anthropic.com>

* plugins: editor.save.before review fixes: flush held typing, update the surviving view, keep the denial

- After the hook answers, pending view text (rich Markdown's serialize timer) is
  flushed before deciding whether the buffer still holds the hook's input.
- The hook's text goes to whichever view of the buffer record is still open, so
  closing the saving view keeps a sibling view in step with disk.
- The save-request bridge passes Errors through whole, so the toast shows the
  plugin and its reason instead of a generic failure.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(plugins): regenerate after rebasing the save hook on main

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-02 06:19:50 +00:00

121 lines
4.3 KiB
TypeScript

import type { PluginCatalogType } from './catalog/plugin-catalog-types'
import type { PluginExecutionProfile } from './contributions/plugin-execution-profile'
import type { PluginHostImages } from './images/plugin-image-url'
import type { PluginStatusBarItemContribution } from './contributions/plugin-status-bar-item-contribution'
/** One installed plugin as main projects it for every client (preload IPC and the `plugins.list`
* RPC). Optional fields are absent on older hosts, so paired clients must tolerate them. */
export type PluginHostPanel = {
id: string
title: string
/** Lucide icon name declared in the plugin manifest. */
icon?: string
tabKey: `plugin:${string}`
}
/** pending awaits consent; idle defers worker startup; errored exhausted retries or failed startup. */
export type PluginHostStatus =
| 'running'
| 'restarting'
| 'idle'
| 'pending'
| 'disabled'
| 'errored'
| 'invalid'
export type PluginHostCustomEditor = {
id: string
title: string
extensions: string[]
filenames: string[]
priority: 'default' | 'option'
/** Absent on hosts that predate byte editors: text. */
contentKind?: 'text' | 'bytes'
/** Absent on hosts that predate the field: `any`. */
frame?: 'any' | 'isolated'
/** orca-plugin-image URL of the editor's tab icon; absent when it declares none. */
icon?: string
}
export type PluginHostNewFile = {
id: string
title: string
/** Suffix, with the dot, of the untitled file it creates. */
extension: string
/** orca-plugin-image URL; absent when it declares none. */
icon?: string
}
export type PluginHostListEntry = {
pluginKey: string
consentFingerprint: string | null
name: string
version: string
publisher: string
description?: string
/** The verified execution profile from the installed manifest; absent for invalid entries and
* on hosts that predate it. Never infer it from the plugin's contents. */
execution?: PluginExecutionProfile
/** What the installed manifest contributes; absent on hosts that predate it. */
types?: readonly PluginCatalogType[]
/** Declared images (format 2 manifests), as orca-plugin-image URLs main serves. */
images?: PluginHostImages
/** SPDX license expression from the manifest. */
license?: string
homepage?: string
repository?: string
status: PluginHostStatus
needsReconsent: boolean
error?: string
isDev: boolean
/** Dev folders: file changes the host had seen when it last loaded the folder, so a client can
* tell a reload happened. Absent on older hosts and installed plugins. */
devChangeCount?: number
official: boolean
bundled: boolean
capabilities: { kind: string; description: string }[]
panels: PluginHostPanel[]
/** Absent on hosts that predate custom editors. */
customEditors?: PluginHostCustomEditor[]
/** Absent on hosts that predate new-file templates. */
newFiles?: PluginHostNewFile[]
commands: {
id: string
title: string
// Why `worktree`: hosts from before the rename to `workspace` still send it.
context: 'global' | 'workspace' | 'worktree'
handler: { type: 'built-in'; action: string } | { type: 'worker' }
keybindings: { key: string; when: 'global' | 'workspace' | 'worktree' }[]
/** The built-in action it runs in place of; absent on older hosts. */
replaces?: string
/** Another plugin, installed earlier, replaces the same action, so this one does not. */
replacedBy?: string
}[]
/** The manifest's status bar items; a worker's changes arrive separately. Absent on older hosts. */
statusBarItems?: PluginStatusBarItemContribution[]
/** The hooks its manifest lists, so a window can skip asking when none applies. Absent on older
* hosts. */
hooks?: string[]
hasWorker: boolean
vmRecipes?: {
id: string
name: string
description?: string
commands: {
phase: 'create' | 'suspend' | 'resume' | 'destroy'
command: string
}[]
}[]
restarts: number
blockedByKillList?: { reason: string; advisoryUrl?: string }
source?: {
kind: 'local-path' | 'git' | 'marketplace' | 'bundled'
reference: string
resolvedCommit: string | null
contentHash: string
/** `ref` is the catalog's ref, so a client can join to the exact catalog source. */
marketplace?: { reference: string; ref?: string; resolvedCommit: string }
}
}