- Added a WindowsTitleBar component to provide a conventional title bar experience when the native frame is hidden. - Integrated window control functionalities (minimize, maximize, close) using IPC handlers for better user interaction. - Updated the TabbedLayout to include the new WindowsTitleBar, ensuring a consistent UI across platforms. - Enhanced the README with additional details about the new window controls feature. This commit improves the user interface on Windows by providing familiar window management controls, enhancing usability and consistency.
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
|
import { contextBridge, ipcRenderer } from 'electron';
|
|
|
|
import {
|
|
CONTEXT_CHANGED,
|
|
CONTEXT_GET_ACTIVE,
|
|
CONTEXT_LIST,
|
|
CONTEXT_SWITCH,
|
|
HTTP_SERVER_GET_STATUS,
|
|
HTTP_SERVER_START,
|
|
HTTP_SERVER_STOP,
|
|
SSH_CONNECT,
|
|
SSH_DISCONNECT,
|
|
SSH_GET_CONFIG_HOSTS,
|
|
SSH_GET_LAST_CONNECTION,
|
|
SSH_GET_STATE,
|
|
SSH_RESOLVE_HOST,
|
|
SSH_SAVE_LAST_CONNECTION,
|
|
SSH_STATUS,
|
|
SSH_TEST,
|
|
UPDATER_CHECK,
|
|
UPDATER_DOWNLOAD,
|
|
UPDATER_INSTALL,
|
|
UPDATER_STATUS,
|
|
WINDOW_CLOSE,
|
|
WINDOW_IS_MAXIMIZED,
|
|
WINDOW_MAXIMIZE,
|
|
WINDOW_MINIMIZE,
|
|
} from './constants/ipcChannels';
|
|
import {
|
|
CONFIG_ADD_IGNORE_REGEX,
|
|
CONFIG_ADD_IGNORE_REPOSITORY,
|
|
CONFIG_ADD_TRIGGER,
|
|
CONFIG_CLEAR_SNOOZE,
|
|
CONFIG_GET,
|
|
CONFIG_GET_TRIGGERS,
|
|
CONFIG_OPEN_IN_EDITOR,
|
|
CONFIG_PIN_SESSION,
|
|
CONFIG_REMOVE_IGNORE_REGEX,
|
|
CONFIG_REMOVE_IGNORE_REPOSITORY,
|
|
CONFIG_REMOVE_TRIGGER,
|
|
CONFIG_SELECT_FOLDERS,
|
|
CONFIG_SNOOZE,
|
|
CONFIG_TEST_TRIGGER,
|
|
CONFIG_UNPIN_SESSION,
|
|
CONFIG_UPDATE,
|
|
CONFIG_UPDATE_TRIGGER,
|
|
} from './constants/ipcChannels';
|
|
|
|
import type {
|
|
AppConfig,
|
|
ContextInfo,
|
|
ElectronAPI,
|
|
HttpServerStatus,
|
|
NotificationTrigger,
|
|
SessionsByIdsOptions,
|
|
SessionsPaginationOptions,
|
|
SshConfigHostEntry,
|
|
SshConnectionConfig,
|
|
SshConnectionStatus,
|
|
SshLastConnection,
|
|
TriggerTestResult,
|
|
} from '@shared/types';
|
|
|
|
// =============================================================================
|
|
// IPC Result Types and Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Standard IPC result structure returned by main process handlers.
|
|
* All config-related IPC calls return this shape.
|
|
*/
|
|
interface IpcResult<T> {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: string;
|
|
}
|
|
|
|
interface IpcFileChangePayload {
|
|
type: 'add' | 'change' | 'unlink';
|
|
path: string;
|
|
projectId?: string;
|
|
sessionId?: string;
|
|
isSubagent: boolean;
|
|
}
|
|
|
|
/**
|
|
* Type-safe IPC invoker for operations that return IpcResult<T>.
|
|
* Throws an Error if the IPC call fails, otherwise returns the typed data.
|
|
*/
|
|
async function invokeIpcWithResult<T>(channel: string, ...args: unknown[]): Promise<T> {
|
|
const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult<T>;
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? 'Unknown error');
|
|
}
|
|
return result.data as T;
|
|
}
|
|
|
|
// Keep latest zoom factor cached even before renderer UI subscribes.
|
|
let currentZoomFactor = 1;
|
|
ipcRenderer.on(
|
|
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
|
(_event: Electron.IpcRendererEvent, zoomFactor: unknown) => {
|
|
if (typeof zoomFactor === 'number' && Number.isFinite(zoomFactor)) {
|
|
currentZoomFactor = zoomFactor;
|
|
}
|
|
}
|
|
);
|
|
|
|
// =============================================================================
|
|
// Electron API Implementation
|
|
// =============================================================================
|
|
|
|
// Expose protected methods that allow the renderer process to use
|
|
// the ipcRenderer without exposing the entire object
|
|
const electronAPI: ElectronAPI = {
|
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
|
getProjects: () => ipcRenderer.invoke('get-projects'),
|
|
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
|
getSessionsPaginated: (
|
|
projectId: string,
|
|
cursor: string | null,
|
|
limit?: number,
|
|
options?: SessionsPaginationOptions
|
|
) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options),
|
|
searchSessions: (projectId: string, query: string, maxResults?: number) =>
|
|
ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
|
|
getSessionDetail: (projectId: string, sessionId: string) =>
|
|
ipcRenderer.invoke('get-session-detail', projectId, sessionId),
|
|
getSessionMetrics: (projectId: string, sessionId: string) =>
|
|
ipcRenderer.invoke('get-session-metrics', projectId, sessionId),
|
|
getWaterfallData: (projectId: string, sessionId: string) =>
|
|
ipcRenderer.invoke('get-waterfall-data', projectId, sessionId),
|
|
getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) =>
|
|
ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId),
|
|
getSessionGroups: (projectId: string, sessionId: string) =>
|
|
ipcRenderer.invoke('get-session-groups', projectId, sessionId),
|
|
getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) =>
|
|
ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds, options),
|
|
|
|
// Repository grouping (worktree support)
|
|
getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'),
|
|
getWorktreeSessions: (worktreeId: string) =>
|
|
ipcRenderer.invoke('get-worktree-sessions', worktreeId),
|
|
|
|
// Validation methods
|
|
validatePath: (relativePath: string, projectPath: string) =>
|
|
ipcRenderer.invoke('validate-path', relativePath, projectPath),
|
|
validateMentions: (mentions: { type: 'path'; value: string }[], projectPath: string) =>
|
|
ipcRenderer.invoke('validate-mentions', mentions, projectPath),
|
|
|
|
// CLAUDE.md reading methods
|
|
readClaudeMdFiles: (projectRoot: string) =>
|
|
ipcRenderer.invoke('read-claude-md-files', projectRoot),
|
|
readDirectoryClaudeMd: (dirPath: string) =>
|
|
ipcRenderer.invoke('read-directory-claude-md', dirPath),
|
|
readMentionedFile: (absolutePath: string, projectRoot: string, maxTokens?: number) =>
|
|
ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens),
|
|
|
|
// Notifications API
|
|
notifications: {
|
|
get: (options?: { limit?: number; offset?: number }) =>
|
|
ipcRenderer.invoke('notifications:get', options),
|
|
markRead: (id: string) => ipcRenderer.invoke('notifications:markRead', id),
|
|
markAllRead: () => ipcRenderer.invoke('notifications:markAllRead'),
|
|
delete: (id: string) => ipcRenderer.invoke('notifications:delete', id),
|
|
clear: () => ipcRenderer.invoke('notifications:clear'),
|
|
getUnreadCount: () => ipcRenderer.invoke('notifications:getUnreadCount'),
|
|
onNew: (callback: (event: unknown, error: unknown) => void): (() => void) => {
|
|
ipcRenderer.on(
|
|
'notification:new',
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
return (): void => {
|
|
ipcRenderer.removeListener(
|
|
'notification:new',
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
};
|
|
},
|
|
onUpdated: (
|
|
callback: (event: unknown, payload: { total: number; unreadCount: number }) => void
|
|
): (() => void) => {
|
|
ipcRenderer.on(
|
|
'notification:updated',
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
return (): void => {
|
|
ipcRenderer.removeListener(
|
|
'notification:updated',
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
};
|
|
},
|
|
onClicked: (callback: (event: unknown, data: unknown) => void): (() => void) => {
|
|
ipcRenderer.on(
|
|
'notification:clicked',
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
return (): void => {
|
|
ipcRenderer.removeListener(
|
|
'notification:clicked',
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
};
|
|
},
|
|
},
|
|
|
|
// Config API - uses typed helper to unwrap { success, data, error } responses
|
|
config: {
|
|
get: async (): Promise<AppConfig> => {
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
update: async (section: string, data: object): Promise<AppConfig> => {
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_UPDATE, section, data);
|
|
},
|
|
addIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_ADD_IGNORE_REGEX, pattern);
|
|
// Re-fetch config after mutation
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
removeIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_REMOVE_IGNORE_REGEX, pattern);
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
addIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_ADD_IGNORE_REPOSITORY, repositoryId);
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
removeIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_REMOVE_IGNORE_REPOSITORY, repositoryId);
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
snooze: async (minutes: number): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_SNOOZE, minutes);
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
clearSnooze: async (): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_CLEAR_SNOOZE);
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
addTrigger: async (trigger: Omit<NotificationTrigger, 'isBuiltin'>): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_ADD_TRIGGER, trigger);
|
|
// Return updated config
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
updateTrigger: async (
|
|
triggerId: string,
|
|
updates: Partial<NotificationTrigger>
|
|
): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_UPDATE_TRIGGER, triggerId, updates);
|
|
// Return updated config
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
removeTrigger: async (triggerId: string): Promise<AppConfig> => {
|
|
await invokeIpcWithResult<void>(CONFIG_REMOVE_TRIGGER, triggerId);
|
|
// Return updated config
|
|
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
|
|
},
|
|
getTriggers: async (): Promise<NotificationTrigger[]> => {
|
|
return invokeIpcWithResult<NotificationTrigger[]>(CONFIG_GET_TRIGGERS);
|
|
},
|
|
testTrigger: async (trigger: NotificationTrigger): Promise<TriggerTestResult> => {
|
|
return invokeIpcWithResult<TriggerTestResult>(CONFIG_TEST_TRIGGER, trigger);
|
|
},
|
|
selectFolders: async (): Promise<string[]> => {
|
|
return invokeIpcWithResult<string[]>(CONFIG_SELECT_FOLDERS);
|
|
},
|
|
openInEditor: async (): Promise<void> => {
|
|
return invokeIpcWithResult<void>(CONFIG_OPEN_IN_EDITOR);
|
|
},
|
|
pinSession: async (projectId: string, sessionId: string): Promise<void> => {
|
|
return invokeIpcWithResult<void>(CONFIG_PIN_SESSION, projectId, sessionId);
|
|
},
|
|
unpinSession: async (projectId: string, sessionId: string): Promise<void> => {
|
|
return invokeIpcWithResult<void>(CONFIG_UNPIN_SESSION, projectId, sessionId);
|
|
},
|
|
},
|
|
|
|
// Deep link navigation
|
|
session: {
|
|
scrollToLine: (sessionId: string, lineNumber: number) =>
|
|
ipcRenderer.invoke('session:scrollToLine', sessionId, lineNumber),
|
|
},
|
|
|
|
// Zoom factor sync (used for traffic-light-safe layout)
|
|
getZoomFactor: async (): Promise<number> => currentZoomFactor,
|
|
onZoomFactorChanged: (callback: (zoomFactor: number) => void): (() => void) => {
|
|
const listener = (_event: Electron.IpcRendererEvent, zoomFactor: unknown): void => {
|
|
if (typeof zoomFactor !== 'number' || !Number.isFinite(zoomFactor)) return;
|
|
currentZoomFactor = zoomFactor;
|
|
callback(zoomFactor);
|
|
};
|
|
ipcRenderer.on(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, listener);
|
|
return (): void => {
|
|
ipcRenderer.removeListener(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, listener);
|
|
};
|
|
},
|
|
|
|
// File change events (real-time updates)
|
|
onFileChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => {
|
|
const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void =>
|
|
callback(data);
|
|
ipcRenderer.on('file-change', listener);
|
|
return (): void => {
|
|
ipcRenderer.removeListener('file-change', listener);
|
|
};
|
|
},
|
|
|
|
// Shell operations
|
|
openPath: (targetPath: string, projectRoot?: string) =>
|
|
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot),
|
|
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
|
|
|
// Window controls (when title bar is hidden, e.g. Windows / Linux)
|
|
windowControls: {
|
|
minimize: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
|
|
maximize: () => ipcRenderer.invoke(WINDOW_MAXIMIZE),
|
|
close: () => ipcRenderer.invoke(WINDOW_CLOSE),
|
|
isMaximized: () => ipcRenderer.invoke(WINDOW_IS_MAXIMIZED) as Promise<boolean>,
|
|
},
|
|
|
|
onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => {
|
|
const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void =>
|
|
callback(data);
|
|
ipcRenderer.on('todo-change', listener);
|
|
return (): void => {
|
|
ipcRenderer.removeListener('todo-change', listener);
|
|
};
|
|
},
|
|
|
|
// Updater API
|
|
updater: {
|
|
check: () => ipcRenderer.invoke(UPDATER_CHECK),
|
|
download: () => ipcRenderer.invoke(UPDATER_DOWNLOAD),
|
|
install: () => ipcRenderer.invoke(UPDATER_INSTALL),
|
|
onStatus: (callback: (event: unknown, status: unknown) => void): (() => void) => {
|
|
ipcRenderer.on(
|
|
UPDATER_STATUS,
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
return (): void => {
|
|
ipcRenderer.removeListener(
|
|
UPDATER_STATUS,
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
};
|
|
},
|
|
},
|
|
|
|
// SSH API
|
|
ssh: {
|
|
connect: async (config: SshConnectionConfig): Promise<SshConnectionStatus> => {
|
|
return invokeIpcWithResult<SshConnectionStatus>(SSH_CONNECT, config);
|
|
},
|
|
disconnect: async (): Promise<SshConnectionStatus> => {
|
|
return invokeIpcWithResult<SshConnectionStatus>(SSH_DISCONNECT);
|
|
},
|
|
getState: async (): Promise<SshConnectionStatus> => {
|
|
return invokeIpcWithResult<SshConnectionStatus>(SSH_GET_STATE);
|
|
},
|
|
test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => {
|
|
return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config);
|
|
},
|
|
getConfigHosts: async (): Promise<SshConfigHostEntry[]> => {
|
|
return invokeIpcWithResult<SshConfigHostEntry[]>(SSH_GET_CONFIG_HOSTS);
|
|
},
|
|
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
|
return invokeIpcWithResult<SshConfigHostEntry | null>(SSH_RESOLVE_HOST, alias);
|
|
},
|
|
saveLastConnection: async (config: SshLastConnection): Promise<void> => {
|
|
return invokeIpcWithResult<void>(SSH_SAVE_LAST_CONNECTION, config);
|
|
},
|
|
getLastConnection: async (): Promise<SshLastConnection | null> => {
|
|
return invokeIpcWithResult<SshLastConnection | null>(SSH_GET_LAST_CONNECTION);
|
|
},
|
|
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => {
|
|
ipcRenderer.on(
|
|
SSH_STATUS,
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
return (): void => {
|
|
ipcRenderer.removeListener(
|
|
SSH_STATUS,
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
};
|
|
},
|
|
},
|
|
|
|
// Context API
|
|
context: {
|
|
list: async (): Promise<ContextInfo[]> => {
|
|
return invokeIpcWithResult<ContextInfo[]>(CONTEXT_LIST);
|
|
},
|
|
getActive: async (): Promise<string> => {
|
|
return invokeIpcWithResult<string>(CONTEXT_GET_ACTIVE);
|
|
},
|
|
switch: async (contextId: string): Promise<{ contextId: string }> => {
|
|
return invokeIpcWithResult<{ contextId: string }>(CONTEXT_SWITCH, contextId);
|
|
},
|
|
onChanged: (callback: (event: unknown, data: ContextInfo) => void): (() => void) => {
|
|
ipcRenderer.on(
|
|
CONTEXT_CHANGED,
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
return (): void => {
|
|
ipcRenderer.removeListener(
|
|
CONTEXT_CHANGED,
|
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
|
);
|
|
};
|
|
},
|
|
},
|
|
|
|
// HTTP Server API
|
|
httpServer: {
|
|
start: async (): Promise<HttpServerStatus> => {
|
|
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_START);
|
|
},
|
|
stop: async (): Promise<HttpServerStatus> => {
|
|
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_STOP);
|
|
},
|
|
getStatus: async (): Promise<HttpServerStatus> => {
|
|
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_GET_STATUS);
|
|
},
|
|
},
|
|
};
|
|
|
|
// Use contextBridge to securely expose the API to the renderer process
|
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|