feat: Plugin API Completeness — file mutations, prompt editor, and attachment APIs

- WorkspaceFiles: writeFile, deleteFile, moveFile with path safety
  - writeFile: text/binary, auto-create dirs, overwrite option
  - deleteFile: idempotent, uses lstat (removes symlinks not targets)
  - moveFile: unix mv semantics, overwrite defaults to false
  - All mutations auto-refreshFiles() in File Explorer
  - Symlink escape prevention via realpath(dirname) check

- PluginPromptEditor: insertText, getText, getSelection, onPaste, onKeyDown, focus
  - Uses CM6 EditorView.domEventHandlers() via Compartment (not raw DOM)
  - Handlers registered before mount are preserved and applied on mount
  - First-to-consume-wins ordering for multi-plugin scenarios
  - insertText replaces selection (not inserts after)

- PluginAttachments: insertFileReference, getAttachedFiles, removeFileReference
  - insertFileReference validates file exists before inserting @path
  - Does not auto-focus editor (unlike prompt.insertText)
  - @file regex requires file extension to avoid matching emails

- Server endpoints: PUT /file, DELETE /file, POST /file/move
  - All work for local and federated machines

- Tests: 31 unit tests, 9 integration tests, 5 client tests
- Docs: 3 new sections in plugins.md
This commit is contained in:
marcus
2026-06-14 15:03:23 +02:00
parent 227187c4ca
commit 27a3b2b5ed
22 changed files with 1314 additions and 32 deletions
+54 -1
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle } from "./shared/apiTypes.js";
import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, DeleteWorkspaceFileResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse } from "./shared/apiTypes.js";
export type {
FileContentMediaType,
@@ -20,6 +20,11 @@ export type {
TerminalCommandRunFilter,
TerminalCommandRunHandle,
TerminalCommandRunStatus,
WriteWorkspaceFileOptions,
WriteWorkspaceFileResponse,
DeleteWorkspaceFileResponse,
MoveWorkspaceFileOptions,
MoveWorkspaceFileResponse,
} from "./shared/apiTypes.js";
export type PluginId = string;
@@ -67,8 +72,46 @@ export interface PluginRuntimeState {
piWebStatus?: PiWebStatusResponse;
}
export interface PluginPromptEditor {
/** Insert text at the current cursor position. Replaces any selection.
* If the editor is not focused, focuses it first.
* No-op if the editor is not mounted. */
insertText(text: string): void;
/** Get the current prompt text content. Returns "" if the editor is not mounted. */
getText(): string;
/** Get the current selection range, or null if no selection or editor not mounted. */
getSelection(): { start: number; end: number; text: string } | null;
/** Register a paste event handler scoped to the prompt editor.
* Handlers run in registration order; first handler returning true consumes the event.
* Returns an unsubscribe function. No-op if the editor is not mounted. */
onPaste(handler: (event: ClipboardEvent) => boolean): () => void;
/** Register a keydown handler scoped to the prompt editor.
* Handlers run in registration order; first handler returning true consumes the event.
* Returns unsubscribe. No-op if the editor is not mounted. */
onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void;
/** Focus the prompt editor. No-op if not mounted. */
focus(): void;
}
export interface PluginAttachments {
/** Insert a file reference at the current cursor position in the chat prompt.
* Validates that the file exists in the workspace before insertion.
* Does not auto-focus the editor (unlike prompt.insertText). Use prompt.focus() first if needed.
* @throws Error if no workspace is selected or the file doesn't exist
* Returns the canonical @file reference string (e.g., "@path/to/file.png"). */
insertFileReference(path: string): Promise<string>;
/** List currently attached file paths in the prompt. Returns paths without the @ prefix.
* Best-effort heuristic: matches @path/to/file.ext patterns. May match email-like patterns;
* use insertFileReference() for guaranteed-accurate insertion. */
getAttachedFiles(): string[];
/** Remove a file reference from the prompt by path. Removes the first occurrence of @path. */
removeFileReference(path: string): void;
}
export interface PluginRuntimeContext {
state: PluginRuntimeState;
prompt: PluginPromptEditor;
attachments: PluginAttachments;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
@@ -109,7 +152,17 @@ export interface Workspace {
}
export interface WorkspaceFiles {
/** Read a file from the workspace. Works for local and federated machines. */
readFile(path: string): Promise<FileContentResponse>;
/** Write content to a workspace file. Creates intermediate directories by default.
* Works for local and federated machines. Auto-refreshes the file explorer after success. */
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
/** Delete a file from the workspace. Idempotent — returns { existed: false } if file doesn't exist.
* Deletes the entry itself (for symlinks, removes the symlink not the target). */
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
/** Move or rename a file within the workspace. Unix mv semantics.
* Default overwrite: false (safer than writeFile). Auto-refreshes the file explorer after success. */
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
}
export type WorkspacePanelFiles = WorkspaceFiles;