openpencil/apps/desktop/git/ipc-handlers.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
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).
2026-07-02 21:21:06 +08:00

355 lines
14 KiB
TypeScript

// apps/desktop/git/ipc-handlers.ts
//
// Thin shim: each handler is a one-line forward to a git-engine function.
// Tests call gitIpcHandlers directly. setupGitIPC wires them onto ipcMain.
//
// Error serialization: Electron's structured-clone drops custom Error
// subclasses (only `message` survives reliably). We tag GitError instances
// by stuffing { __gitError, code, message, recoverable } into the message
// field as JSON, prefixed with a marker the renderer detects on receive.
import { ipcMain } from 'electron';
import { GitError, isGitError } from './error';
import {
engineDetect,
engineInit,
engineOpen,
engineBindTrackedFile,
engineListCandidates,
engineClose,
engineStatus,
engineLog,
engineCommit,
engineRestore,
enginePromote,
engineBranchList,
engineBranchCreate,
engineBranchSwitch,
engineBranchDelete,
engineClone,
engineFetch,
enginePull,
enginePush,
engineDiff,
engineBranchMerge,
engineResolveConflict,
engineApplyMerge,
engineAbortMerge,
engineRemoteGet,
engineRemoteSet,
setSshKeyManager,
setAuthStore,
} from './git-engine';
import type { ConflictResolution } from './merge-session';
import { createDefaultAuthStore, type AuthCreds, type AuthStore } from './auth-store';
import { createDefaultSshKeyManager, type SshKeyInfo, type SshKeyManager } from './ssh-keys';
import { getSystemAuthor as sysGetSystemAuthor } from './git-sys';
// ---------------------------------------------------------------------------
// Module-level singletons assigned by setupGitIPC at boot. We require Electron
// to be ready (app.whenReady()) before instantiating because both auth-store
// and ssh-keys lazy-import electron's `app` and `safeStorage`.
// ---------------------------------------------------------------------------
let authStore: AuthStore | null = null;
let sshKeyManager: SshKeyManager | null = null;
function requireAuthStore(): AuthStore {
if (!authStore) throw new Error('auth store not initialized; call setupGitIPC() first');
return authStore;
}
function requireSshKeyManager(): SshKeyManager {
if (!sshKeyManager) throw new Error('ssh key manager not initialized; call setupGitIPC() first');
return sshKeyManager;
}
/**
* Strip the on-disk private key path before returning SSH key info to the
* renderer. The path is backend-only — the renderer never needs it because
* SSH transport is invoked via git-sys with GIT_SSH_COMMAND, never via the
* renderer-side filesystem.
*/
type PublicSshKeyInfo = Omit<SshKeyInfo, 'privateKeyPath'>;
function stripPrivatePath(info: SshKeyInfo): PublicSshKeyInfo {
const { privateKeyPath: _omit, ...rest } = info;
void _omit;
return rest;
}
const GIT_ERROR_MARKER = '__GIT_ERROR__';
/**
* Serialize a GitError into an Error whose message is JSON-encoded with the
* GIT_ERROR_MARKER prefix. The renderer's git-client (Phase 3) detects the
* marker and rehydrates a GitError on its side.
*/
export function serializeGitError(err: GitError): Error {
const payload = {
code: err.code,
message: err.message,
recoverable: err.recoverable,
};
return new Error(`${GIT_ERROR_MARKER}${JSON.stringify(payload)}`);
}
/**
* Wrap a handler so any thrown GitError becomes a serialized Error suitable
* for crossing the IPC boundary. Other errors propagate as-is (their
* `message` survives clone but the stack/type does not).
*/
async function runHandler<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (isGitError(err)) {
throw serializeGitError(err as GitError);
}
throw err;
}
}
// ---------------------------------------------------------------------------
// Direct handler functions — exported so tests can call them without ipcMain.
// Each is a one-line forward to engineX(). Argument shapes mirror the IPC
// contract section of the spec.
// ---------------------------------------------------------------------------
export const gitIpcHandlers = {
detect: (filePath: string) => engineDetect(filePath),
init: (filePath: string) => engineInit(filePath),
open: (repoPath: string, currentFilePath?: string) => engineOpen(repoPath, currentFilePath),
bindTrackedFile: (repoId: string, filePath: string) => engineBindTrackedFile(repoId, filePath),
listCandidates: (repoId: string) => engineListCandidates(repoId),
close: (repoId: string) => {
engineClose(repoId);
return Promise.resolve();
},
status: (repoId: string) => engineStatus(repoId),
log: (repoId: string, opts: { ref: 'main' | 'autosaves' | string; limit: number }) =>
engineLog(repoId, opts),
commit: (
repoId: string,
opts: {
kind: 'milestone' | 'autosave';
message: string;
author: { name: string; email: string };
},
) => engineCommit(repoId, opts),
restore: (repoId: string, commitHash: string) => engineRestore(repoId, commitHash),
promote: (
repoId: string,
autosaveHash: string,
message: string,
author: { name: string; email: string },
) => enginePromote(repoId, autosaveHash, message, author),
branchList: (repoId: string) => engineBranchList(repoId),
branchCreate: (repoId: string, opts: { name: string; fromCommit?: string }) =>
engineBranchCreate(repoId, opts),
branchSwitch: (repoId: string, name: string) => engineBranchSwitch(repoId, name),
branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) =>
engineBranchDelete(repoId, name, opts),
// Phase 2b: remote ops
clone: (opts: { url: string; dest: string; auth?: AuthCreds }) => engineClone(opts),
fetch: (repoId: string, auth?: AuthCreds) => engineFetch(repoId, auth),
pull: (repoId: string, auth?: AuthCreds) => enginePull(repoId, auth),
push: (repoId: string, auth?: AuthCreds) => enginePush(repoId, auth),
// Phase 6a: remote metadata + config (no network)
remoteGet: (repoId: string) => engineRemoteGet(repoId),
remoteSet: (repoId: string, url: string | null) => engineRemoteSet(repoId, url),
// Phase 2b: auth
authStore: (host: string, creds: AuthCreds) => requireAuthStore().set(host, creds),
authGet: (host: string) => requireAuthStore().get(host),
authClear: (host: string) => requireAuthStore().clear(host),
// Phase 2b: ssh keys (privateKeyPath stripped before crossing IPC)
sshListKeys: async (): Promise<PublicSshKeyInfo[]> => {
const all = await requireSshKeyManager().list();
return all.map(stripPrivatePath);
},
sshGenerateKey: async (opts: { host: string; comment: string }): Promise<PublicSshKeyInfo> => {
const info = await requireSshKeyManager().generate(opts);
return stripPrivatePath(info);
},
sshImportKey: async (opts: {
privateKeyPath: string;
host: string;
}): Promise<PublicSshKeyInfo> => {
const info = await requireSshKeyManager().import(opts);
return stripPrivatePath(info);
},
sshDeleteKey: (keyId: string) => requireSshKeyManager().delete(keyId),
// Phase 2c: merge orchestration
diff: (repoId: string, fromCommit: string, toCommit: string) =>
engineDiff(repoId, fromCommit, toCommit),
branchMerge: (repoId: string, fromBranch: string) => engineBranchMerge(repoId, fromBranch),
resolveConflict: (repoId: string, conflictId: string, choice: ConflictResolution) =>
engineResolveConflict(repoId, conflictId, choice),
applyMerge: (repoId: string) => engineApplyMerge(repoId),
abortMerge: (repoId: string) => engineAbortMerge(repoId),
// Phase 4a: author identity probe (system git config)
getSystemAuthor: () => sysGetSystemAuthor(),
};
// ---------------------------------------------------------------------------
// ipcMain registration. Each channel is registered exactly once. Calling
// setupGitIPC twice would throw on the second call (ipcMain.handle rejects
// duplicate channel names) — main.ts must ensure single invocation.
// ---------------------------------------------------------------------------
export function setupGitIPC(): void {
// Lazy-instantiate the auth/ssh singletons. These pull in Electron, so they
// must run AFTER app.whenReady() (which is when main.ts calls setupGitIPC).
authStore = createDefaultAuthStore();
sshKeyManager = createDefaultSshKeyManager();
// Inject both into the engine so engineClone/Fetch/Pull/Push can:
// - look up stored host credentials when no explicit auth was passed
// - resolve SSH keyIds to private key file paths
setAuthStore(authStore);
setSshKeyManager(sshKeyManager);
ipcMain.handle('git:detect', (_e, filePath: string) =>
runHandler(() => gitIpcHandlers.detect(filePath)),
);
ipcMain.handle('git:init', (_e, filePath: string) =>
runHandler(() => gitIpcHandlers.init(filePath)),
);
ipcMain.handle('git:open', (_e, repoPath: string, currentFilePath?: string) =>
runHandler(() => gitIpcHandlers.open(repoPath, currentFilePath)),
);
ipcMain.handle('git:bindTrackedFile', (_e, repoId: string, filePath: string) =>
runHandler(() => gitIpcHandlers.bindTrackedFile(repoId, filePath)),
);
ipcMain.handle('git:listCandidates', (_e, repoId: string) =>
runHandler(() => gitIpcHandlers.listCandidates(repoId)),
);
ipcMain.handle('git:close', (_e, repoId: string) =>
runHandler(() => gitIpcHandlers.close(repoId)),
);
ipcMain.handle('git:status', (_e, repoId: string) =>
runHandler(() => gitIpcHandlers.status(repoId)),
);
ipcMain.handle(
'git:log',
(_e, repoId: string, opts: { ref: 'main' | 'autosaves' | string; limit: number }) =>
runHandler(() => gitIpcHandlers.log(repoId, opts)),
);
ipcMain.handle(
'git:commit',
(
_e,
repoId: string,
opts: {
kind: 'milestone' | 'autosave';
message: string;
author: { name: string; email: string };
},
) => runHandler(() => gitIpcHandlers.commit(repoId, opts)),
);
ipcMain.handle('git:restore', (_e, repoId: string, commitHash: string) =>
runHandler(() => gitIpcHandlers.restore(repoId, commitHash)),
);
ipcMain.handle(
'git:promote',
(
_e,
repoId: string,
autosaveHash: string,
message: string,
author: { name: string; email: string },
) => runHandler(() => gitIpcHandlers.promote(repoId, autosaveHash, message, author)),
);
ipcMain.handle('git:branchList', (_e, repoId: string) =>
runHandler(() => gitIpcHandlers.branchList(repoId)),
);
ipcMain.handle(
'git:branchCreate',
(_e, repoId: string, opts: { name: string; fromCommit?: string }) =>
runHandler(() => gitIpcHandlers.branchCreate(repoId, opts)),
);
ipcMain.handle('git:branchSwitch', (_e, repoId: string, name: string) =>
runHandler(() => gitIpcHandlers.branchSwitch(repoId, name)),
);
ipcMain.handle(
'git:branchDelete',
(_e, repoId: string, name: string, opts?: { force?: boolean }) =>
runHandler(() => gitIpcHandlers.branchDelete(repoId, name, opts)),
);
// ---- Phase 2b: remote ops ------------------------------------------------
ipcMain.handle('git:clone', (_e, opts: { url: string; dest: string; auth?: AuthCreds }) =>
runHandler(() => gitIpcHandlers.clone(opts)),
);
ipcMain.handle('git:fetch', (_e, repoId: string, auth?: AuthCreds) =>
runHandler(() => gitIpcHandlers.fetch(repoId, auth)),
);
ipcMain.handle('git:pull', (_e, repoId: string, auth?: AuthCreds) =>
runHandler(() => gitIpcHandlers.pull(repoId, auth)),
);
ipcMain.handle('git:push', (_e, repoId: string, auth?: AuthCreds) =>
runHandler(() => gitIpcHandlers.push(repoId, auth)),
);
// ---- Phase 6a: remote metadata + config ---------------------------------
ipcMain.handle('git:remoteGet', (_e, repoId: string) =>
runHandler(() => gitIpcHandlers.remoteGet(repoId)),
);
ipcMain.handle('git:remoteSet', (_e, repoId: string, url: string | null) =>
runHandler(() => gitIpcHandlers.remoteSet(repoId, url)),
);
// ---- Phase 2b: auth ------------------------------------------------------
ipcMain.handle('git:authStore', (_e, host: string, creds: AuthCreds) =>
runHandler(() => gitIpcHandlers.authStore(host, creds)),
);
ipcMain.handle('git:authGet', (_e, host: string) =>
runHandler(() => gitIpcHandlers.authGet(host)),
);
ipcMain.handle('git:authClear', (_e, host: string) =>
runHandler(() => gitIpcHandlers.authClear(host)),
);
// ---- Phase 2b: ssh keys --------------------------------------------------
ipcMain.handle('git:sshListKeys', () => runHandler(() => gitIpcHandlers.sshListKeys()));
ipcMain.handle('git:sshGenerateKey', (_e, opts: { host: string; comment: string }) =>
runHandler(() => gitIpcHandlers.sshGenerateKey(opts)),
);
ipcMain.handle('git:sshImportKey', (_e, opts: { privateKeyPath: string; host: string }) =>
runHandler(() => gitIpcHandlers.sshImportKey(opts)),
);
ipcMain.handle('git:sshDeleteKey', (_e, keyId: string) =>
runHandler(() => gitIpcHandlers.sshDeleteKey(keyId)),
);
// ---- Phase 2c: merge orchestration --------------------------------------
ipcMain.handle('git:diff', (_e, repoId: string, fromCommit: string, toCommit: string) =>
runHandler(() => gitIpcHandlers.diff(repoId, fromCommit, toCommit)),
);
ipcMain.handle('git:branchMerge', (_e, repoId: string, fromBranch: string) =>
runHandler(() => gitIpcHandlers.branchMerge(repoId, fromBranch)),
);
ipcMain.handle(
'git:resolveConflict',
(_e, repoId: string, conflictId: string, choice: ConflictResolution) =>
runHandler(() => gitIpcHandlers.resolveConflict(repoId, conflictId, choice)),
);
ipcMain.handle('git:applyMerge', (_e, repoId: string) =>
runHandler(() => gitIpcHandlers.applyMerge(repoId)),
);
ipcMain.handle('git:abortMerge', (_e, repoId: string) =>
runHandler(() => gitIpcHandlers.abortMerge(repoId)),
);
// Phase 4a: author identity probe
ipcMain.handle('git:getSystemAuthor', () => runHandler(() => gitIpcHandlers.getSystemAuthor()));
}
/** Exposed for the renderer-side rehydrator (Phase 3). Tests use it too. */
export { GIT_ERROR_MARKER };