Add Windows Electron desktop support (#1093)
* fix: make upstream sync actions target the selected remote Ensure fetch and pull actually honor upstream selection so fork maintenance works from the Git sidebar, and surface upstream branch status alongside the primary origin-tracking indicators. * feat: add Windows Electron desktop foundation * fix(electron): stabilize Windows desktop packaging * fix(electron): stabilize Windows desktop chrome Use native Windows titlebar behavior with an Alt-accessible hidden menu, and harden Windows dev command launching so the desktop app follows platform conventions. * fix(electron): stabilize Windows dev startup * fix(electron): clarify desktop artifact names * fix(electron): harden Windows desktop release and launch * fix(electron): address Windows release review * fix(electron): point updater and release links to org repo * Fix Windows settings persistence fallback * Fix Windows Electron dev startup * Add Windows Electron window controls * Fix Windows Electron install and opencode launch * fix: resolve git status for repositories without upstream Fixes repository detection stuck on Checking repository Handles git status when no upstream is configured Adds regression coverage for git status loading * Add Windows app menu button * fix: preserve file editor line endings * ci: add desktop release smoke workflow --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
cc7969ac00
commit
becd240168
@@ -77,7 +77,7 @@ type HeaderIconActionButtonProps = {
|
||||
visible?: boolean;
|
||||
title: string;
|
||||
ariaLabel: string;
|
||||
onClick: () => void;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
className?: string;
|
||||
Icon: IconName;
|
||||
iconClassName?: string;
|
||||
@@ -115,6 +115,83 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
|
||||
);
|
||||
});
|
||||
|
||||
type WindowsWindowControlsProps = {
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
const WindowsWindowControls = React.memo(function WindowsWindowControls({ visible }: WindowsWindowControlsProps) {
|
||||
const { t } = useI18n();
|
||||
const [isMaximized, setIsMaximized] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
void invokeDesktop<{ maximized?: boolean }>('desktop_get_current_window_state')
|
||||
.then((state) => {
|
||||
if (!disposed) {
|
||||
setIsMaximized(Boolean(state?.maximized));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const handleMaximizedChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ maximized?: boolean }>).detail;
|
||||
setIsMaximized(Boolean(detail?.maximized));
|
||||
};
|
||||
|
||||
window.addEventListener('openchamber:window-maximized-changed', handleMaximizedChange);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.removeEventListener('openchamber:window-maximized-changed', handleMaximizedChange);
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonClassName = 'app-region-no-drag inline-flex h-12 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary';
|
||||
|
||||
return (
|
||||
<div className="app-region-no-drag -mr-3 ml-2 flex h-12 shrink-0 items-center" aria-label={t('header.windowControls.groupAria')}>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClassName}
|
||||
onClick={() => { void invokeDesktop('desktop_minimize_current_window'); }}
|
||||
title={t('header.windowControls.minimize')}
|
||||
aria-label={t('header.windowControls.minimize')}
|
||||
>
|
||||
<Icon name="subtract" className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClassName}
|
||||
onClick={() => {
|
||||
void invokeDesktop<{ maximized?: boolean }>('desktop_toggle_current_window_maximized')
|
||||
.then((state) => setIsMaximized(Boolean(state?.maximized)))
|
||||
.catch(() => {});
|
||||
}}
|
||||
title={isMaximized ? t('header.windowControls.restore') : t('header.windowControls.maximize')}
|
||||
aria-label={isMaximized ? t('header.windowControls.restore') : t('header.windowControls.maximize')}
|
||||
>
|
||||
<Icon name={isMaximized ? 'fullscreen-exit' : 'checkbox-blank'} className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(buttonClassName, 'hover:bg-status-error hover:text-status-error-foreground')}
|
||||
onClick={() => { void invokeDesktop('desktop_close_current_window'); }}
|
||||
title={t('header.windowControls.close')}
|
||||
aria-label={t('header.windowControls.close')}
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type DesktopGitHubControlProps = {
|
||||
isMobile: boolean;
|
||||
githubAuthStatus: GitHubAuthStatus | null;
|
||||
@@ -731,6 +808,13 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
||||
}, []);
|
||||
|
||||
const isWindowsElectronDesktop = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(window.__OPENCHAMBER_ELECTRON__) && window.__OPENCHAMBER_PLATFORM__ === 'win32';
|
||||
}, []);
|
||||
|
||||
const macosMajorVersion = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
@@ -1262,6 +1346,16 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
toggleSidebar();
|
||||
}, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]);
|
||||
|
||||
const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
void invokeDesktop('desktop_show_app_menu', {
|
||||
x: rect.left,
|
||||
y: rect.bottom,
|
||||
}).catch((error) => {
|
||||
console.warn('[header] failed to open app menu', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOpenDraftMiniChat = React.useCallback(() => {
|
||||
void invokeDesktop('desktop_open_draft_mini_chat_window', {
|
||||
directory: normalize(openDirectory || activeProject?.path || ''),
|
||||
@@ -1454,7 +1548,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}, [isDesktopApp, isMacPlatform, macosMajorVersion]);
|
||||
|
||||
const webWindowControlsOverlayStyle = React.useMemo<React.CSSProperties | undefined>(() => {
|
||||
if (isDesktopApp || isVSCode) {
|
||||
if ((isDesktopApp && !isWindowsElectronDesktop) || isVSCode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1466,7 +1560,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
|
||||
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
|
||||
};
|
||||
}, [isDesktopApp, isTabletStandalonePwa, isVSCode]);
|
||||
}, [isDesktopApp, isTabletStandalonePwa, isVSCode, isWindowsElectronDesktop]);
|
||||
|
||||
const updateHeaderHeight = React.useCallback(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
@@ -1884,6 +1978,15 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
role="tablist"
|
||||
aria-label={t('header.navigation.mainAria')}
|
||||
>
|
||||
{isWindowsElectronDesktop ? (
|
||||
<HeaderIconActionButton
|
||||
title={t('header.actions.openAppMenu')}
|
||||
ariaLabel={t('header.actions.openAppMenuAria')}
|
||||
onClick={handleOpenWindowsAppMenu}
|
||||
className={`${desktopHeaderIconButtonClass} shrink-0`}
|
||||
Icon={'menu-2'}
|
||||
/>
|
||||
) : null}
|
||||
<HeaderIconActionButton
|
||||
title={t('header.actions.openSessionsWithShortcut', { shortcut: shortcutLabel('toggle_sidebar') })}
|
||||
ariaLabel={t('header.actions.openSessionsAria')}
|
||||
@@ -1974,6 +2077,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
Icon={'picture-in-picture-2'}
|
||||
/>
|
||||
{desktopSidebarActions}
|
||||
<WindowsWindowControls visible={isWindowsElectronDesktop} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,7 +319,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
|
||||
export const SidebarFilesTree: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { files, runtime } = useRuntimeAPIs();
|
||||
const { files } = useRuntimeAPIs();
|
||||
const currentDirectory = useEffectiveDirectory() ?? '';
|
||||
const root = normalizePath(currentDirectory.trim());
|
||||
const showHidden = useDirectoryShowHidden();
|
||||
@@ -335,6 +335,7 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
|
||||
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
|
||||
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
|
||||
const loadedDirsRef = React.useRef<Set<string>>(new Set());
|
||||
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -419,7 +420,7 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
inFlightDirsRef.current.add(normalizedDir);
|
||||
|
||||
const respectGitignore = !showGitignored;
|
||||
const listPromise = runtime.isDesktop
|
||||
const listPromise = files.listDirectory
|
||||
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
@@ -437,25 +438,34 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
|
||||
loadedDirsRef.current = new Set(loadedDirsRef.current);
|
||||
loadedDirsRef.current.add(normalizedDir);
|
||||
setLoadErrorsByDir((prev) => {
|
||||
if (!prev[normalizedDir]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[normalizedDir];
|
||||
return next;
|
||||
});
|
||||
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
|
||||
})
|
||||
.catch(() => {
|
||||
setChildrenByDir((prev) => ({
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||
console.error('Failed to load sidebar directory:', error);
|
||||
setLoadErrorsByDir((prev) => ({
|
||||
...prev,
|
||||
[normalizedDir]: prev[normalizedDir] ?? [],
|
||||
[normalizedDir]: message,
|
||||
}));
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
|
||||
inFlightDirsRef.current.delete(normalizedDir);
|
||||
});
|
||||
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
|
||||
}, [files, mapDirectoryEntries, showGitignored]);
|
||||
|
||||
const refreshRoot = React.useCallback(async () => {
|
||||
if (!root) return;
|
||||
|
||||
loadedDirsRef.current = new Set();
|
||||
inFlightDirsRef.current = new Set();
|
||||
setLoadErrorsByDir({});
|
||||
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||||
|
||||
await loadDirectory(root);
|
||||
@@ -484,6 +494,7 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
|
||||
loadedDirsRef.current = new Set();
|
||||
inFlightDirsRef.current = new Set();
|
||||
setLoadErrorsByDir({});
|
||||
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||||
void loadDirectory(root);
|
||||
}, [loadDirectory, root, showHidden, showGitignored]);
|
||||
@@ -807,6 +818,7 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
}
|
||||
|
||||
const hasTree = Boolean(root && childrenByDir[root]);
|
||||
const rootLoadError = root ? loadErrorsByDir[root] : null;
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
|
||||
@@ -923,6 +935,14 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : rootLoadError ? (
|
||||
<li className="flex flex-col gap-2 px-2 py-1 typography-meta text-muted-foreground">
|
||||
<span>{rootLoadError}</span>
|
||||
<Button variant="outline" size="xs" className="w-fit gap-1.5" onClick={() => void refreshRoot()}>
|
||||
<Icon name="refresh" className="h-3.5 w-3.5" />
|
||||
{t('sidebarFilesTree.actions.refreshTitle')}
|
||||
</Button>
|
||||
</li>
|
||||
) : hasTree && root ? (
|
||||
renderTree(root, 0)
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user