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.
419 lines
13 KiB
TypeScript
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]);
|
|
};
|