Files
openchamber/packages/ui/src/hooks/useMenuActions.ts
T
Bohdan Triapitsyn c82f188fc8 refactor(surface): remove the main-area surface concept entirely
activeSurface was permanently 'chat' after the legacy mobile layout
removal, so the whole concept is gone: the store field, surfaceGuard,
setActiveSurface/setSurfaceGuard, the per-runtime surface memory in
prepare/restoreForRuntimeSwitch, and WorkspaceSurface itself. All ~30
setActiveSurface('chat') call sites were no-ops and are deleted;
always-true 'is the chat active' checks in keyboard shortcuts, Header
and ChatContainer are unconditional now. FilesView's dirty-file guard
kept its file-switch and close protection but drops the surface-switch
branch nothing could trigger. TerminalView visibility comes only from
its callers. The router keeps parsing legacy ?tab= links (they open the
matching context-panel surface) via its own RouteTab type and no longer
serializes a tab or diff file into URLs — desktop URLs never carried
them anyway.
2026-08-24 16:36:41 +03:00

419 lines
13 KiB
TypeScript

import React from 'react';
import { toast } from '@/components/ui';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncSessions } from '@/sync/sync-refs';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { sessionEvents } from '@/lib/sessionEvents';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
import { addSelectionToChat } from '@/lib/addSelectionToChat';
const getActiveElementSelectedText = (): string => {
if (typeof document === 'undefined') {
return '';
}
const activeElement = document.activeElement;
if (activeElement instanceof HTMLTextAreaElement) {
return activeElement.value.slice(activeElement.selectionStart ?? 0, activeElement.selectionEnd ?? 0);
}
if (activeElement instanceof HTMLInputElement) {
const type = activeElement.type?.toLowerCase() ?? 'text';
if (['text', 'search', 'url', 'tel', 'password'].includes(type)) {
return activeElement.value.slice(activeElement.selectionStart ?? 0, activeElement.selectionEnd ?? 0);
}
}
if (activeElement instanceof HTMLElement && activeElement.isContentEditable) {
return activeElement.ownerDocument.defaultView?.getSelection?.()?.toString() ?? '';
}
return '';
};
const copyCurrentSelectionFallback = async (): Promise<boolean> => {
const selectionText = getActiveElementSelectedText() || window.getSelection()?.toString() || '';
if (!selectionText.trim()) {
return false;
}
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(selectionText);
return true;
}
} catch {
// Fall through to execCommand fallback when Clipboard API is unavailable.
}
return document.execCommand('copy');
};
const MENU_ACTION_EVENT = 'openchamber:menu-action';
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
type DesktopBridgeGlobal = {
listen?: (
event: string,
handler: (evt: { payload?: unknown }) => void
) => Promise<() => void>;
};
type MenuAction =
| 'about'
| 'settings'
| 'command-palette'
| 'quick-open'
| 'new-session'
| 'new-worktree-session'
| 'change-workspace'
| 'toggle-right-sidebar'
| 'open-right-sidebar-git'
| 'open-right-sidebar-files'
| 'toggle-terminal'
| 'toggle-terminal-expanded'
| 'copy'
| 'add-selection-to-chat'
| 'theme-light'
| 'theme-dark'
| 'theme-system'
| 'toggle-sidebar'
| 'toggle-memory-debug'
| 'go-back'
| 'go-forward'
| 'previous-session'
| 'next-session'
| 'previous-project'
| 'next-project'
| 'help-dialog'
| 'download-logs';
export const useMenuActions = (
onToggleMemoryDebug?: () => void
) => {
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette);
const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
const { setThemeMode } = useThemeSystem();
const checkUpdatesInFlightRef = React.useRef(false);
const handleCheckForUpdates = React.useCallback(() => {
if (checkUpdatesInFlightRef.current) {
return;
}
checkUpdatesInFlightRef.current = true;
void checkForUpdates()
.then(() => {
const { available, error } = useUpdateStore.getState();
if (error) {
toast.error('Failed to check for updates', {
description: error,
});
return;
}
if (!available) {
toast.success('You are on the latest version');
}
})
.finally(() => {
checkUpdatesInFlightRef.current = false;
});
}, [checkForUpdates]);
const handleChangeWorkspace = React.useCallback(() => {
sessionEvents.requestDirectoryDialog();
}, []);
const navigateSession = React.useCallback((direction: -1 | 1) => {
const sessions = getSyncSessions();
if (sessions.length === 0) return;
const currentSessionId = useSessionUIStore.getState().currentSessionId;
const currentIndex = sessions.findIndex((session) => session.id === currentSessionId);
let nextIndex = direction > 0 ? 0 : sessions.length - 1;
if (currentIndex >= 0) {
nextIndex = (currentIndex + direction + sessions.length) % sessions.length;
}
const nextSession = sessions[nextIndex];
if (!nextSession) return;
setSessionSwitcherOpen(false);
useSessionUIStore.getState().setCurrentSession(nextSession.id);
}, [setSessionSwitcherOpen]);
const navigateProject = React.useCallback((direction: -1 | 1) => {
const { activeProjectId, projects, setActiveProject } = useProjectsStore.getState();
if (projects.length === 0) return;
const currentIndex = projects.findIndex((project) => project.id === activeProjectId);
let nextIndex = direction > 0 ? 0 : projects.length - 1;
if (currentIndex >= 0) {
nextIndex = (currentIndex + direction + projects.length) % projects.length;
}
const nextProject = projects[nextIndex];
if (!nextProject) return;
setActiveProject(nextProject.id);
}, []);
const handleAction = React.useCallback(
(action: MenuAction) => {
switch (action) {
case 'about':
setAboutDialogOpen(true);
break;
case 'settings':
setSettingsDialogOpen(true);
break;
case 'command-palette':
toggleCommandPalette();
break;
case 'quick-open':
setCommandPaletteOpen(true);
break;
case 'new-session':
setSessionSwitcherOpen(false);
{
const sessionState = useSessionUIStore.getState();
const directory = useDirectoryStore.getState().currentDirectory;
openNewSessionDraft(sessionState.currentSessionId && directory
? { directoryOverride: directory }
: undefined);
}
break;
case 'new-worktree-session':
setSessionSwitcherOpen(false);
createWorktreeSession();
break;
case 'change-workspace':
handleChangeWorkspace();
break;
// Legacy right-sidebar menu items now target the context surfaces
// that replaced the sidebar's tabs.
case 'toggle-right-sidebar': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
const uiState = useUIStore.getState();
const directoryKey = normalizeContextPanelDirectoryKey(directory);
const panelState = uiState.contextPanelByDirectory[directoryKey];
if (panelState?.isOpen) {
uiState.closeContextPanel(directoryKey);
} else if (panelState?.activeTabId) {
uiState.setActiveContextPanelTab(directoryKey, panelState.activeTabId);
} else {
uiState.openContextSurface(directoryKey, 'git');
}
break;
}
case 'open-right-sidebar-git': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(directory), 'git');
break;
}
case 'open-right-sidebar-files': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(directory), 'file');
break;
}
case 'toggle-terminal': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(directory), 'terminal');
break;
}
case 'toggle-terminal-expanded': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
const key = normalizeContextPanelDirectoryKey(directory);
const uiState = useUIStore.getState();
const panel = uiState.contextPanelByDirectory[key];
const activeMode = panel?.isOpen ? panel.tabs.find((tab) => tab.id === panel.activeTabId)?.mode : null;
if (activeMode !== 'terminal') {
uiState.openContextSurface(key, 'terminal');
}
uiState.toggleContextPanelExpanded(key);
break;
}
case 'copy': {
const copyEvent = new Event('openchamber:copy', { cancelable: true });
const wasHandled = !window.dispatchEvent(copyEvent);
if (!wasHandled) {
void copyCurrentSelectionFallback();
}
break;
}
case 'theme-light':
setThemeMode('light');
break;
case 'theme-dark':
setThemeMode('dark');
break;
case 'theme-system':
setThemeMode('system');
break;
case 'add-selection-to-chat':
addSelectionToChat();
break;
case 'toggle-sidebar':
toggleSidebar();
break;
case 'toggle-memory-debug':
onToggleMemoryDebug?.();
break;
case 'go-back':
useDirectoryStore.getState().goBack();
break;
case 'go-forward':
useDirectoryStore.getState().goForward();
break;
case 'previous-session':
navigateSession(-1);
break;
case 'next-session':
navigateSession(1);
break;
case 'previous-project':
navigateProject(-1);
break;
case 'next-project':
navigateProject(1);
break;
case 'help-dialog':
toggleHelpDialog();
break;
case 'download-logs': {
void showOpenCodeStatus().catch(() => {
toast.error('Failed to collect OpenCode status');
});
break;
}
}
},
[
handleChangeWorkspace,
navigateProject,
navigateSession,
onToggleMemoryDebug,
openNewSessionDraft,
setAboutDialogOpen,
setSessionSwitcherOpen,
setCommandPaletteOpen,
setSettingsDialogOpen,
setThemeMode,
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
]
);
React.useEffect(() => {
const handleMenuAction = (event: Event) => {
const action = (event as CustomEvent<MenuAction>).detail;
if (!action) return;
handleAction(action);
};
const handleCheckForUpdatesEvent = () => {
handleCheckForUpdates();
};
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
window.addEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
return () => {
window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
window.removeEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
};
}, [handleAction, handleCheckForUpdates]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
const listen = desktop?.listen;
if (typeof listen !== 'function') return;
let unlistenMenu: null | (() => void | Promise<void>) = null;
let unlistenUpdate: null | (() => void | Promise<void>) = null;
listen('openchamber:menu-action', (evt) => {
const action = evt?.payload;
if (typeof action !== 'string') return;
handleAction(action as MenuAction);
})
.then((fn) => {
unlistenMenu = fn;
})
.catch(() => {
// ignore
});
listen('openchamber:check-for-updates', () => {
window.dispatchEvent(new Event(CHECK_FOR_UPDATES_EVENT));
})
.then((fn) => {
unlistenUpdate = fn;
})
.catch(() => {
// ignore
});
return () => {
const cleanup = async () => {
try {
const a = unlistenMenu?.();
if (a instanceof Promise) await a;
} catch {
// ignore
}
try {
const b = unlistenUpdate?.();
if (b instanceof Promise) await b;
} catch {
// ignore
}
};
void cleanup();
};
}, [handleAction]);
};