Built-in design generation now runs as an agentic MCP tool-loop (reusing the agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the Settings experimental toggle; the orchestrator stays the default. - design-agent system prompt + in-process design toolset (parity-locked with the MCP surface) + flag-gated Intent::Design routing - spawn_agents execution as sequential sub-loops + live creation-mode badges (per-agent glow + 'N/M designing...' header) - new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state, export_nodes, spawn_agents; style-guide local audit - #27 AI panel restyle: rounded tool cards + green check-rings, gray user bubbles, model-pill bottom toolbar, header, empty-state pills, the PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown - multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI (switch / close / + / Cmd+T) with each run bound to its tab Large checkpoint commit spanning the working tree (Rust shell crates).
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron';
|
|
|
|
export type UpdaterStatus =
|
|
| 'disabled'
|
|
| 'idle'
|
|
| 'checking'
|
|
| 'available'
|
|
| 'downloading'
|
|
| 'downloaded'
|
|
| 'not-available'
|
|
| 'error';
|
|
|
|
export interface UpdaterState {
|
|
status: UpdaterStatus;
|
|
currentVersion: string;
|
|
latestVersion?: string;
|
|
downloadProgress?: number;
|
|
releaseDate?: string;
|
|
error?: string;
|
|
}
|
|
|
|
// ---- Git API types (Phase 2a) ------------------------------------------------
|
|
// These mirror the engine's RepoOpenInfo / StatusInfo / CommitMeta / BranchInfo
|
|
// shapes so the renderer can type its IPC calls. The marker-based GitError
|
|
// rehydration is implemented on the renderer side in Phase 3.
|
|
|
|
export interface GitCandidateFileInfo {
|
|
path: string;
|
|
relativePath: string;
|
|
milestoneCount: number;
|
|
autosaveCount: number;
|
|
lastCommitAt: number | null;
|
|
lastCommitMessage: string | null;
|
|
}
|
|
|
|
export interface GitRepoOpenInfo {
|
|
repoId: string;
|
|
mode: 'single-file' | 'folder';
|
|
rootPath: string;
|
|
gitdir: string;
|
|
engineKind: 'iso' | 'sys';
|
|
trackedFilePath: string | null;
|
|
candidates: GitCandidateFileInfo[];
|
|
}
|
|
|
|
export interface GitConflictBag {
|
|
nodeConflicts: Array<{
|
|
id: string;
|
|
pageId: string | null;
|
|
nodeId: string;
|
|
reason:
|
|
| 'both-modified-same-field'
|
|
| 'modify-vs-delete'
|
|
| 'add-vs-add-different'
|
|
| 'reparent-conflict';
|
|
base: unknown;
|
|
ours: unknown;
|
|
theirs: unknown;
|
|
}>;
|
|
docFieldConflicts: Array<{
|
|
id: string;
|
|
field: string;
|
|
path: string;
|
|
base: unknown;
|
|
ours: unknown;
|
|
theirs: unknown;
|
|
}>;
|
|
}
|
|
|
|
export type GitConflictResolution =
|
|
| { kind: 'ours' }
|
|
| { kind: 'theirs' }
|
|
| { kind: 'manual-node'; node: unknown }
|
|
| { kind: 'manual-field'; value: unknown };
|
|
|
|
export interface GitStatusInfo {
|
|
branch: string;
|
|
trackedFilePath: string | null;
|
|
workingDirty: boolean;
|
|
otherFilesDirty: number;
|
|
otherFilesPaths: string[];
|
|
ahead: number;
|
|
behind: number;
|
|
mergeInProgress: boolean;
|
|
unresolvedFiles: string[];
|
|
conflicts: GitConflictBag | null;
|
|
}
|
|
|
|
export interface GitCommitMeta {
|
|
hash: string;
|
|
parentHashes: string[];
|
|
message: string;
|
|
author: { name: string; email: string; timestamp: number };
|
|
kind: 'milestone' | 'autosave';
|
|
}
|
|
|
|
export interface GitBranchInfo {
|
|
name: string;
|
|
isCurrent: boolean;
|
|
ahead: number;
|
|
behind: number;
|
|
lastCommit: { hash: string; message: string; timestamp: number } | null;
|
|
}
|
|
|
|
/**
|
|
* Phase 6a: renderer-visible remote metadata for the single 'origin' remote.
|
|
* Mirrors apps/web/src/services/git-types.ts so the renderer can type its
|
|
* IPC calls without importing from the desktop side.
|
|
*/
|
|
export interface GitRemoteInfo {
|
|
name: 'origin';
|
|
url: string | null;
|
|
host: string | null;
|
|
}
|
|
|
|
export interface GitAPI {
|
|
detect: (filePath: string) => Promise<{ mode: 'none' } | GitRepoOpenInfo>;
|
|
init: (filePath: string) => Promise<GitRepoOpenInfo>;
|
|
open: (repoPath: string, currentFilePath?: string) => Promise<GitRepoOpenInfo>;
|
|
bindTrackedFile: (repoId: string, filePath: string) => Promise<{ trackedFilePath: string }>;
|
|
listCandidates: (repoId: string) => Promise<GitCandidateFileInfo[]>;
|
|
close: (repoId: string) => Promise<void>;
|
|
status: (repoId: string) => Promise<GitStatusInfo>;
|
|
log: (
|
|
repoId: string,
|
|
opts: { ref: 'main' | 'autosaves' | string; limit: number },
|
|
) => Promise<GitCommitMeta[]>;
|
|
commit: (
|
|
repoId: string,
|
|
opts: {
|
|
kind: 'milestone' | 'autosave';
|
|
message: string;
|
|
author: { name: string; email: string };
|
|
},
|
|
) => Promise<{ hash: string }>;
|
|
restore: (repoId: string, commitHash: string) => Promise<void>;
|
|
promote: (
|
|
repoId: string,
|
|
autosaveHash: string,
|
|
message: string,
|
|
author: { name: string; email: string },
|
|
) => Promise<{ hash: string }>;
|
|
branchList: (repoId: string) => Promise<GitBranchInfo[]>;
|
|
branchCreate: (repoId: string, opts: { name: string; fromCommit?: string }) => Promise<void>;
|
|
branchSwitch: (repoId: string, name: string) => Promise<void>;
|
|
branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) => Promise<void>;
|
|
|
|
// ---- Phase 2b: remote ops ----
|
|
clone: (opts: {
|
|
url: string;
|
|
dest: string;
|
|
auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string };
|
|
}) => Promise<GitRepoOpenInfo>;
|
|
fetch: (
|
|
repoId: string,
|
|
auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string },
|
|
) => Promise<{ ahead: number; behind: number }>;
|
|
pull: (
|
|
repoId: string,
|
|
auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string },
|
|
) => Promise<{
|
|
result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op';
|
|
conflicts?: GitConflictBag;
|
|
}>;
|
|
push: (
|
|
repoId: string,
|
|
auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string },
|
|
) => Promise<{ result: 'ok' }>;
|
|
|
|
// ---- Phase 2b: auth ----
|
|
authStore: (
|
|
host: string,
|
|
creds: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string },
|
|
) => Promise<void>;
|
|
authGet: (
|
|
host: string,
|
|
) => Promise<
|
|
{ kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string } | null
|
|
>;
|
|
authClear: (host: string) => Promise<void>;
|
|
|
|
// ---- Phase 2b: ssh keys (privateKeyPath stripped) ----
|
|
sshListKeys: () => Promise<
|
|
Array<{
|
|
id: string;
|
|
host: string;
|
|
publicKey: string;
|
|
fingerprint: string;
|
|
comment: string;
|
|
}>
|
|
>;
|
|
sshGenerateKey: (opts: { host: string; comment: string }) => Promise<{
|
|
id: string;
|
|
host: string;
|
|
publicKey: string;
|
|
fingerprint: string;
|
|
comment: string;
|
|
}>;
|
|
sshImportKey: (opts: { privateKeyPath: string; host: string }) => Promise<{
|
|
id: string;
|
|
host: string;
|
|
publicKey: string;
|
|
fingerprint: string;
|
|
comment: string;
|
|
}>;
|
|
sshDeleteKey: (keyId: string) => Promise<void>;
|
|
|
|
// ---- Phase 2c: merge orchestration ----
|
|
diff: (
|
|
repoId: string,
|
|
fromCommit: string,
|
|
toCommit: string,
|
|
) => Promise<{
|
|
summary: {
|
|
framesChanged: number;
|
|
nodesAdded: number;
|
|
nodesRemoved: number;
|
|
nodesModified: number;
|
|
};
|
|
patches: unknown[];
|
|
}>;
|
|
branchMerge: (
|
|
repoId: string,
|
|
fromBranch: string,
|
|
) => Promise<{
|
|
result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op';
|
|
conflicts?: GitConflictBag;
|
|
}>;
|
|
resolveConflict: (
|
|
repoId: string,
|
|
conflictId: string,
|
|
choice: GitConflictResolution,
|
|
) => Promise<void>;
|
|
applyMerge: (repoId: string) => Promise<{ hash: string; noop: boolean }>;
|
|
abortMerge: (repoId: string) => Promise<void>;
|
|
|
|
// Phase 4a: author identity probe (system git config)
|
|
getSystemAuthor: () => Promise<{ name: string; email: string } | null>;
|
|
|
|
// Phase 6a: remote metadata + config (no network)
|
|
remoteGet: (repoId: string) => Promise<GitRemoteInfo>;
|
|
remoteSet: (repoId: string, url: string | null) => Promise<GitRemoteInfo>;
|
|
}
|
|
|
|
export interface ElectronAPI {
|
|
isElectron: true;
|
|
openFile: () => Promise<{ filePath: string; content: string } | null>;
|
|
openImageFile: () => Promise<{ filePath: string; name: string; content: string | null } | null>;
|
|
openDirectory: () => Promise<string | null>;
|
|
saveFile: (content: string, defaultPath?: string) => Promise<string | null>;
|
|
saveToPath: (filePath: string, content: string) => Promise<string>;
|
|
onMenuAction: (callback: (action: string) => void) => () => void;
|
|
onOpenFile: (callback: (filePath: string) => void) => () => void;
|
|
readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null>;
|
|
getPendingFile: () => Promise<string | null>;
|
|
getLogDir: () => Promise<string>;
|
|
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void;
|
|
getPreferences: () => Promise<Record<string, string>>;
|
|
setPreference: (key: string, value: string) => Promise<void>;
|
|
removePreference: (key: string) => Promise<void>;
|
|
confirmClose: () => void;
|
|
confirmUnsavedChanges: (payload: {
|
|
message: string;
|
|
detail?: string;
|
|
yesLabel: string;
|
|
noLabel: string;
|
|
cancelLabel: string;
|
|
}) => Promise<'save' | 'discard' | 'cancel'>;
|
|
syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => void;
|
|
/** Resolve the absolute filesystem path of a File object obtained from drag-and-drop. */
|
|
getPathForFile: (file: File) => string | null;
|
|
updater: {
|
|
getState: () => Promise<UpdaterState>;
|
|
checkForUpdates: () => Promise<UpdaterState>;
|
|
quitAndInstall: () => Promise<boolean>;
|
|
getAutoCheck: () => Promise<boolean>;
|
|
setAutoCheck: (enabled: boolean) => Promise<boolean>;
|
|
onStateChange: (callback: (state: UpdaterState) => void) => () => void;
|
|
};
|
|
git: GitAPI;
|
|
}
|
|
|
|
const api: ElectronAPI = {
|
|
isElectron: true,
|
|
|
|
openFile: () => ipcRenderer.invoke('dialog:openFile'),
|
|
|
|
openImageFile: () => ipcRenderer.invoke('dialog:openImageFile'),
|
|
|
|
openDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
|
|
|
|
saveFile: (content: string, defaultPath?: string) =>
|
|
ipcRenderer.invoke('dialog:saveFile', { content, defaultPath }),
|
|
|
|
saveToPath: (filePath: string, content: string) =>
|
|
ipcRenderer.invoke('dialog:saveToPath', { filePath, content }),
|
|
|
|
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) =>
|
|
ipcRenderer.invoke('theme:set', theme, colors),
|
|
|
|
getPreferences: () => ipcRenderer.invoke('prefs:getAll'),
|
|
|
|
setPreference: (key: string, value: string) => ipcRenderer.invoke('prefs:set', key, value),
|
|
|
|
removePreference: (key: string) => ipcRenderer.invoke('prefs:remove', key),
|
|
|
|
onMenuAction: (callback: (action: string) => void) => {
|
|
const listener = (_event: IpcRendererEvent, action: string) => {
|
|
callback(action);
|
|
};
|
|
ipcRenderer.on('menu:action', listener);
|
|
return () => {
|
|
ipcRenderer.removeListener('menu:action', listener);
|
|
};
|
|
},
|
|
|
|
onOpenFile: (callback: (filePath: string) => void) => {
|
|
const listener = (_event: IpcRendererEvent, filePath: string) => {
|
|
callback(filePath);
|
|
};
|
|
ipcRenderer.on('file:open', listener);
|
|
return () => {
|
|
ipcRenderer.removeListener('file:open', listener);
|
|
};
|
|
},
|
|
|
|
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
|
|
|
|
getPendingFile: () => ipcRenderer.invoke('file:getPending'),
|
|
|
|
syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) =>
|
|
ipcRenderer.send('recent-files:sync', files),
|
|
|
|
getPathForFile: (file: File) => {
|
|
try {
|
|
const p = webUtils.getPathForFile(file);
|
|
return p && p.length > 0 ? p : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
confirmClose: () => ipcRenderer.send('window:confirmClose'),
|
|
|
|
confirmUnsavedChanges: (payload) => ipcRenderer.invoke('dialog:confirmUnsavedChanges', payload),
|
|
|
|
getLogDir: () => ipcRenderer.invoke('log:getDir'),
|
|
|
|
updater: {
|
|
getState: () => ipcRenderer.invoke('updater:getState'),
|
|
checkForUpdates: () => ipcRenderer.invoke('updater:checkForUpdates'),
|
|
quitAndInstall: () => ipcRenderer.invoke('updater:quitAndInstall'),
|
|
getAutoCheck: () => ipcRenderer.invoke('updater:getAutoCheck'),
|
|
setAutoCheck: (enabled: boolean) => ipcRenderer.invoke('updater:setAutoCheck', enabled),
|
|
onStateChange: (callback: (state: UpdaterState) => void) => {
|
|
const listener = (_event: IpcRendererEvent, state: UpdaterState) => {
|
|
callback(state);
|
|
};
|
|
ipcRenderer.on('updater:state', listener);
|
|
return () => {
|
|
ipcRenderer.removeListener('updater:state', listener);
|
|
};
|
|
},
|
|
},
|
|
|
|
git: {
|
|
detect: (filePath: string) => ipcRenderer.invoke('git:detect', filePath),
|
|
init: (filePath: string) => ipcRenderer.invoke('git:init', filePath),
|
|
open: (repoPath: string, currentFilePath?: string) =>
|
|
ipcRenderer.invoke('git:open', repoPath, currentFilePath),
|
|
bindTrackedFile: (repoId: string, filePath: string) =>
|
|
ipcRenderer.invoke('git:bindTrackedFile', repoId, filePath),
|
|
listCandidates: (repoId: string) => ipcRenderer.invoke('git:listCandidates', repoId),
|
|
close: (repoId: string) => ipcRenderer.invoke('git:close', repoId),
|
|
status: (repoId: string) => ipcRenderer.invoke('git:status', repoId),
|
|
log: (repoId, opts) => ipcRenderer.invoke('git:log', repoId, opts),
|
|
commit: (repoId, opts) => ipcRenderer.invoke('git:commit', repoId, opts),
|
|
restore: (repoId: string, commitHash: string) =>
|
|
ipcRenderer.invoke('git:restore', repoId, commitHash),
|
|
promote: (repoId, autosaveHash, message, author) =>
|
|
ipcRenderer.invoke('git:promote', repoId, autosaveHash, message, author),
|
|
branchList: (repoId: string) => ipcRenderer.invoke('git:branchList', repoId),
|
|
branchCreate: (repoId, opts) => ipcRenderer.invoke('git:branchCreate', repoId, opts),
|
|
branchSwitch: (repoId: string, name: string) =>
|
|
ipcRenderer.invoke('git:branchSwitch', repoId, name),
|
|
branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) =>
|
|
ipcRenderer.invoke('git:branchDelete', repoId, name, opts),
|
|
|
|
// Phase 2b: remote ops
|
|
clone: (opts) => ipcRenderer.invoke('git:clone', opts),
|
|
fetch: (repoId, auth) => ipcRenderer.invoke('git:fetch', repoId, auth),
|
|
pull: (repoId, auth) => ipcRenderer.invoke('git:pull', repoId, auth),
|
|
push: (repoId, auth) => ipcRenderer.invoke('git:push', repoId, auth),
|
|
|
|
// Phase 2b: auth
|
|
authStore: (host, creds) => ipcRenderer.invoke('git:authStore', host, creds),
|
|
authGet: (host) => ipcRenderer.invoke('git:authGet', host),
|
|
authClear: (host) => ipcRenderer.invoke('git:authClear', host),
|
|
|
|
// Phase 2b: ssh keys
|
|
sshListKeys: () => ipcRenderer.invoke('git:sshListKeys'),
|
|
sshGenerateKey: (opts) => ipcRenderer.invoke('git:sshGenerateKey', opts),
|
|
sshImportKey: (opts) => ipcRenderer.invoke('git:sshImportKey', opts),
|
|
sshDeleteKey: (keyId) => ipcRenderer.invoke('git:sshDeleteKey', keyId),
|
|
|
|
// Phase 2c: merge orchestration
|
|
diff: (repoId, fromCommit, toCommit) =>
|
|
ipcRenderer.invoke('git:diff', repoId, fromCommit, toCommit),
|
|
branchMerge: (repoId, fromBranch) => ipcRenderer.invoke('git:branchMerge', repoId, fromBranch),
|
|
resolveConflict: (repoId, conflictId, choice) =>
|
|
ipcRenderer.invoke('git:resolveConflict', repoId, conflictId, choice),
|
|
applyMerge: (repoId) => ipcRenderer.invoke('git:applyMerge', repoId),
|
|
abortMerge: (repoId) => ipcRenderer.invoke('git:abortMerge', repoId),
|
|
|
|
// Phase 4a: author identity probe
|
|
getSystemAuthor: () => ipcRenderer.invoke('git:getSystemAuthor'),
|
|
|
|
// Phase 6a: remote metadata + config
|
|
remoteGet: (repoId: string) => ipcRenderer.invoke('git:remoteGet', repoId),
|
|
remoteSet: (repoId: string, url: string | null) =>
|
|
ipcRenderer.invoke('git:remoteSet', repoId, url),
|
|
},
|
|
};
|
|
|
|
contextBridge.exposeInMainWorld('electronAPI', api);
|