refactor: migrate to VS Code titlebar drag region pattern
This commit is contained in:
@@ -68,7 +68,7 @@ import {
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
@@ -476,6 +476,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
@@ -494,10 +495,13 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update title bar theme", error);
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
foregroundColor: theme.colors.foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [settingsLoading, resolvedTheme]);
|
||||
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { getDesktopWindow, updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
|
||||
const DEFAULT_OVERLAY_HEIGHT = 29;
|
||||
const MAX_EFFECTIVE_TITLEBAR_HEIGHT = 48;
|
||||
|
||||
/**
|
||||
* VS Code-style titlebar drag region for Electron on Windows/Linux.
|
||||
*
|
||||
* Architecture copied from VS Code at commit daa0a70:
|
||||
* - titlebarPart.ts:463-464 → dedicated absolute drag overlay element
|
||||
* - titlebarpart.css:57-64 → drag region styles
|
||||
* - titlebarpart.css:249-260 → top-edge resizer with no-drag, hidden in fullscreen
|
||||
* - electron-browser/titlebarPart.ts:205-206 → resizer appended to root container
|
||||
*
|
||||
* This component renders two elements:
|
||||
* 1. A full-size absolute drag overlay with -webkit-app-region: drag
|
||||
* 2. A 4px top-edge resizer with -webkit-app-region: no-drag (hidden in fullscreen)
|
||||
*
|
||||
* Both are positioned absolute within the parent container. Interactive children
|
||||
* of the parent sit above the drag overlay via normal stacking order.
|
||||
* The global no-drag reset in index.html remains only as a defensive backstop.
|
||||
*/
|
||||
|
||||
function useIsFullscreen(): boolean {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
async function setup() {
|
||||
const win = getDesktopWindow();
|
||||
if (!win) return;
|
||||
|
||||
const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fs);
|
||||
|
||||
if (typeof win.onResized !== "function") return;
|
||||
|
||||
const unlisten = await win.onResized(async () => {
|
||||
if (disposed) return;
|
||||
const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fullscreen);
|
||||
});
|
||||
|
||||
cleanup = unlisten;
|
||||
if (disposed) cleanup?.();
|
||||
}
|
||||
|
||||
void setup();
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isFullscreen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the VS Code-style drag overlay and top-edge resizer.
|
||||
* Only renders on Electron Windows/Linux (non-mac). Returns null otherwise.
|
||||
*
|
||||
* Usage: Place as a child of any positioned container that should be draggable.
|
||||
* Interactive elements in the same container will sit above the drag overlay
|
||||
* via normal DOM stacking order (later siblings paint over earlier ones).
|
||||
* Place TitlebarDragRegion BEFORE interactive children in JSX.
|
||||
*/
|
||||
export function TitlebarDragRegion() {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TitlebarDragRegionInner />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit no-drag content layer above the drag overlay.
|
||||
* Matches VS Code's pattern of z-index: 2500 + -webkit-app-region: no-drag
|
||||
* on interactive containers within the titlebar (titlebarpart.css:141-143,
|
||||
* 231-233, 264-268, 288-298, 382-401).
|
||||
*
|
||||
* Wrap interactive children of any TitlebarDragRegion parent in this component
|
||||
* so the no-drag layering is structural, not dependent on the global CSS backstop.
|
||||
* On non-Electron or mac, renders children directly with no wrapper.
|
||||
*/
|
||||
interface TitlebarNoDragContentProps {
|
||||
children: ReactNode;
|
||||
/** Layout direction for the no-drag wrapper. Default: "row" for header-style surfaces. */
|
||||
direction?: "row" | "column";
|
||||
}
|
||||
|
||||
export function TitlebarNoDragContent({ children, direction = "row" }: TitlebarNoDragContentProps) {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 2500,
|
||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||
WebkitAppRegion: "no-drag",
|
||||
display: "flex",
|
||||
flexDirection: direction,
|
||||
alignItems: "center",
|
||||
justifyContent: direction === "column" ? "center" : undefined,
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitlebarDragRegionInner() {
|
||||
const isFullscreen = useIsFullscreen();
|
||||
const dragRegionRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastReportedHeightRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dragRegion = dragRegionRef.current;
|
||||
const parent = dragRegion?.parentElement;
|
||||
if (!dragRegion || !parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let frameId: number | null = null;
|
||||
|
||||
const reportHeight = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHeight = Math.min(
|
||||
MAX_EFFECTIVE_TITLEBAR_HEIGHT,
|
||||
Math.max(DEFAULT_OVERLAY_HEIGHT, Math.round(parent.getBoundingClientRect().height)),
|
||||
);
|
||||
|
||||
if (lastReportedHeightRef.current === nextHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastReportedHeightRef.current = nextHeight;
|
||||
void updateDesktopWindowControls({ height: nextHeight }).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls height", error);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleReport = () => {
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
frameId = requestAnimationFrame(() => {
|
||||
frameId = null;
|
||||
reportHeight();
|
||||
});
|
||||
};
|
||||
|
||||
scheduleReport();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "function"
|
||||
? new ResizeObserver(() => {
|
||||
scheduleReport();
|
||||
})
|
||||
: null;
|
||||
|
||||
resizeObserver?.observe(parent);
|
||||
window.addEventListener("resize", scheduleReport);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", scheduleReport);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Drag overlay — equivalent to VS Code .titlebar-drag-region */}
|
||||
<div
|
||||
ref={dragRegionRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||
WebkitAppRegion: "drag",
|
||||
zIndex: 0,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
/>
|
||||
{/* Top-edge resizer — equivalent to VS Code .resizer */}
|
||||
{!isFullscreen && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: 4,
|
||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||
WebkitAppRegion: "no-drag",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
isCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
left?: ReactNode;
|
||||
@@ -31,8 +32,6 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
@@ -45,10 +44,12 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
||||
},
|
||||
borderless && styles.borderless,
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||
<TitlebarDragRegion />
|
||||
<TitlebarNoDragContent>
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||
</TitlebarNoDragContent>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -61,6 +62,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
inner: {},
|
||||
row: {
|
||||
position: "relative",
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
@@ -638,7 +639,6 @@ function DesktopSidebar({
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
@@ -689,11 +689,14 @@ function DesktopSidebar({
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
</View>
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||
<View style={styles.sidebarHeader}>
|
||||
<TitlebarDragRegion />
|
||||
<TitlebarNoDragContent>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
</View>
|
||||
</TitlebarNoDragContent>
|
||||
</View>
|
||||
|
||||
{isInitialLoad ? (
|
||||
@@ -832,6 +835,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarHeader: {
|
||||
position: "relative",
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
|
||||
import {
|
||||
getDesktopHost,
|
||||
type DesktopWindowBridge,
|
||||
type DesktopWindowControlsOverlayUpdate,
|
||||
} from "@/desktop/host";
|
||||
|
||||
export function getDesktopWindow(): DesktopWindowBridge | null {
|
||||
const getter = getDesktopHost()?.window?.getCurrentWindow;
|
||||
@@ -28,11 +32,13 @@ export async function isDesktopFullscreen(): Promise<boolean> {
|
||||
return await win.isFullscreen();
|
||||
}
|
||||
|
||||
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
|
||||
export async function updateDesktopWindowControls(
|
||||
update: DesktopWindowControlsOverlayUpdate,
|
||||
): Promise<void> {
|
||||
const win = getDesktopWindow();
|
||||
if (!win || typeof win.setTitleBarTheme !== "function") {
|
||||
if (!win || typeof win.updateWindowControls !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
await win.setTitleBarTheme(theme);
|
||||
await win.updateWindowControls(update);
|
||||
}
|
||||
|
||||
@@ -44,14 +44,17 @@ export interface DesktopMenuBridge {
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopWindowControlsOverlayUpdate {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
}
|
||||
|
||||
export interface DesktopWindowBridge {
|
||||
label?: string;
|
||||
startMove?: (screenX: number, screenY: number) => void;
|
||||
moving?: (screenX: number, screenY: number) => void;
|
||||
endMove?: () => void;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
|
||||
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void,
|
||||
) => Promise<() => void> | (() => void);
|
||||
|
||||
@@ -51,7 +51,7 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
@@ -243,7 +243,6 @@ function DraftAgentScreenContent({
|
||||
const activateExplorerTabForCheckout = usePanelStore(
|
||||
(state) => state.activateExplorerTabForCheckout,
|
||||
);
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const draftIdRef = useRef(generateDraftId());
|
||||
const draftAgentIdRef = useRef(generateDraftId());
|
||||
@@ -999,8 +998,10 @@ function DraftAgentScreenContent({
|
||||
const explorerServerId = draftExplorerCheckout?.serverId ?? null;
|
||||
const explorerIsGit = draftExplorerCheckout?.isGit ?? false;
|
||||
const mainContent = (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={styles.outerContainer}>
|
||||
<View style={styles.container}>
|
||||
<TitlebarDragRegion />
|
||||
<TitlebarNoDragContent direction="column">
|
||||
<View style={styles.outerContainer}>
|
||||
<View style={styles.agentPanel}>
|
||||
<View
|
||||
style={[
|
||||
@@ -1263,6 +1264,7 @@ function DraftAgentScreenContent({
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</TitlebarNoDragContent>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1292,6 +1294,7 @@ function DraftAgentScreenContent({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||
@@ -18,7 +18,6 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
||||
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompactLayout) {
|
||||
@@ -29,23 +28,26 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader borderless />
|
||||
<View style={styles.content} {...dragHandlers}>
|
||||
<View style={styles.logo}>
|
||||
<PaseoLogo size={56} />
|
||||
</View>
|
||||
<View style={styles.headingGroup}>
|
||||
<Text style={styles.heading}>What shall we build today?</Text>
|
||||
{hasHydrated && !hasProjects ? (
|
||||
<Text style={styles.subtitle}>
|
||||
Add a project folder to start running agents on your codebase
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.cta}>
|
||||
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
|
||||
Add a project
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<TitlebarDragRegion />
|
||||
<TitlebarNoDragContent direction="column">
|
||||
<View style={styles.logo}>
|
||||
<PaseoLogo size={56} />
|
||||
</View>
|
||||
<View style={styles.headingGroup}>
|
||||
<Text style={styles.heading}>What shall we build today?</Text>
|
||||
{hasHydrated && !hasProjects ? (
|
||||
<Text style={styles.subtitle}>
|
||||
Add a project folder to start running agents on your codebase
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.cta}>
|
||||
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
|
||||
Add a project
|
||||
</Button>
|
||||
</View>
|
||||
</TitlebarNoDragContent>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -58,6 +60,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
userSelect: "none",
|
||||
},
|
||||
content: {
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getDesktopDaemonLogs,
|
||||
type DesktopDaemonLogs,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
type StartupSplashScreenProps = {
|
||||
bootstrapState?: {
|
||||
@@ -26,6 +26,7 @@ const DOCS_URL = "https://paseo.sh/docs";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -140,7 +141,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||
const [logsError, setLogsError] = useState<string | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
@@ -222,39 +222,47 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
|
||||
if (isSimpleSplash) {
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.subtitle}>Starting up…</Text>
|
||||
<View style={styles.container}>
|
||||
<TitlebarDragRegion />
|
||||
<TitlebarNoDragContent direction="column">
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.subtitle}>Starting up…</Text>
|
||||
</TitlebarNoDragContent>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isError) {
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={styles.centeredContent}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<View style={styles.progressSteps}>
|
||||
{progressSteps.map((step) => (
|
||||
<View key={step.key} style={styles.progressStepRow}>
|
||||
{step.status === "complete" ? (
|
||||
<Check size={18} color={theme.colors.success} />
|
||||
) : (
|
||||
<ActivityIndicator color={theme.colors.accent} />
|
||||
)}
|
||||
<Text style={styles.subtitleInline}>{step.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.container}>
|
||||
<TitlebarDragRegion />
|
||||
<TitlebarNoDragContent direction="column">
|
||||
<View style={styles.centeredContent}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<View style={styles.progressSteps}>
|
||||
{progressSteps.map((step) => (
|
||||
<View key={step.key} style={styles.progressStepRow}>
|
||||
{step.status === "complete" ? (
|
||||
<Check size={18} color={theme.colors.success} />
|
||||
) : (
|
||||
<ActivityIndicator color={theme.colors.accent} />
|
||||
)}
|
||||
<Text style={styles.subtitleInline}>{step.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TitlebarNoDragContent>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
|
||||
<View style={styles.errorContent}>
|
||||
<View style={[styles.container, styles.containerError]}>
|
||||
<TitlebarDragRegion />
|
||||
<TitlebarNoDragContent direction="column">
|
||||
<View style={styles.errorContent}>
|
||||
<View style={styles.errorHeader}>
|
||||
<PaseoLogo size={64} />
|
||||
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
||||
@@ -313,6 +321,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</TitlebarNoDragContent>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
||||
|
||||
describe("readFiniteScreenPoint", () => {
|
||||
it("returns finite screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
|
||||
screenX: 1280,
|
||||
screenY: 720,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects NaN screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects infinite screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint(undefined)).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
export type DesktopWindowScreenPoint = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
type ScreenPointInput =
|
||||
| {
|
||||
screenX?: unknown;
|
||||
screenY?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function isFiniteCoordinate(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
|
||||
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
screenX: input.screenX,
|
||||
screenY: input.screenY,
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isInteractiveDesktopDragTarget } from "./desktop-window";
|
||||
|
||||
function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget {
|
||||
return {
|
||||
closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null),
|
||||
} as unknown as EventTarget;
|
||||
}
|
||||
|
||||
describe("isInteractiveDesktopDragTarget", () => {
|
||||
it("treats focusable pressables as interactive drag exemptions", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: (selector) => selector.includes("[tabindex]"),
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats semantic button targets as interactive drag exemptions", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: (selector) => selector.includes("[role='button']"),
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-interactive targets", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: () => false,
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the target does not support closest()", () => {
|
||||
expect(isInteractiveDesktopDragTarget(null)).toBe(false);
|
||||
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
getIsElectronRuntimeMac,
|
||||
getIsElectronRuntime,
|
||||
@@ -9,120 +9,7 @@ import {
|
||||
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
||||
|
||||
export async function toggleMaximize() {
|
||||
const win = getDesktopWindow();
|
||||
if (win && typeof win.toggleMaximize === "function") {
|
||||
try {
|
||||
await win.toggleMaximize();
|
||||
} catch (error) {
|
||||
console.warn("[DesktopWindow] toggleMaximize failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual window dragging via pointer events.
|
||||
// Mirrors the Tauri implementation: single pointerdown handler with
|
||||
// double-click-to-maximize via timing, closest() for interactive check,
|
||||
// and pointer capture for move tracking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INTERACTIVE_SELECTOR =
|
||||
"button, a, input, textarea, select, " +
|
||||
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
|
||||
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
|
||||
"[role='menuitem'], [tabindex], [contenteditable='true']";
|
||||
|
||||
const DOUBLE_CLICK_MS = 300;
|
||||
|
||||
type DesktopDragViewProps = Pick<
|
||||
ViewProps,
|
||||
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
|
||||
>;
|
||||
|
||||
export function isInteractiveDesktopDragTarget(target: unknown): boolean {
|
||||
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
|
||||
if (!candidate || typeof candidate.closest !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(candidate.closest(INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
export function useDesktopDragHandlers(): DesktopDragViewProps {
|
||||
const isDragging = useRef(false);
|
||||
const lastPointerDownAt = useRef(0);
|
||||
const isActive = Platform.OS === "web" && isElectronRuntime();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
function handleBlur() {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
getDesktopWindow()?.endMove?.();
|
||||
}
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => window.removeEventListener("blur", handleBlur);
|
||||
}, [isActive]);
|
||||
|
||||
return useMemo((): DesktopDragViewProps => {
|
||||
if (!isActive) return {};
|
||||
|
||||
function stopDrag(e: RNPointerEvent) {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
|
||||
const el = e.currentTarget as unknown as Element | null;
|
||||
if (el && "releasePointerCapture" in el) {
|
||||
el.releasePointerCapture(e.nativeEvent.pointerId);
|
||||
}
|
||||
getDesktopWindow()?.endMove?.();
|
||||
}
|
||||
|
||||
return {
|
||||
onPointerDown: (e: RNPointerEvent) => {
|
||||
if (e.nativeEvent.button !== 0) return;
|
||||
|
||||
if (isInteractiveDesktopDragTarget(e.target)) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
|
||||
lastPointerDownAt.current = 0;
|
||||
void toggleMaximize();
|
||||
return;
|
||||
}
|
||||
lastPointerDownAt.current = now;
|
||||
|
||||
const win = getDesktopWindow();
|
||||
if (!win?.startMove) return;
|
||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
|
||||
if (!screenPoint) return;
|
||||
|
||||
isDragging.current = true;
|
||||
const el = e.currentTarget as unknown as Element;
|
||||
el.setPointerCapture(e.nativeEvent.pointerId);
|
||||
win.startMove(screenPoint.screenX, screenPoint.screenY);
|
||||
},
|
||||
onPointerMove: (e: RNPointerEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
|
||||
if (!screenPoint) {
|
||||
stopDrag(e);
|
||||
return;
|
||||
}
|
||||
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY);
|
||||
},
|
||||
onPointerUp: stopDrag,
|
||||
onPointerCancel: stopDrag,
|
||||
};
|
||||
}, [isActive]);
|
||||
}
|
||||
|
||||
type RawWindowControlsPadding = {
|
||||
left: number;
|
||||
|
||||
Reference in New Issue
Block a user