Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getLocalEnvironmentBearerToken,
getSystemLocale,
getWindowFullscreenState,
setWindowButtonVisibility,
openExternal,
openSystemSettings,
checkSystemPermission,
Expand Down Expand Up @@ -78,6 +79,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handleSync(getAppBranding);
yield* ipc.handleSync(getSystemLocale);
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handle(setWindowButtonVisibility);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handle(getLocalEnvironmentBearerToken);

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const SET_WINDOW_BUTTON_VISIBILITY_CHANNEL = "desktop:set-window-button-visibility";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready";
Expand Down
56 changes: 56 additions & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ vi.mock("electron", () => ({
BrowserWindow: { fromWebContents: ownerWindow },
}));

import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import {
getLocalEnvironmentBootstraps,
getWindowFullscreenState,
setWindowButtonVisibility,
pasteAsText,
pickProjectFavicon,
} from "./window.ts";
Expand Down Expand Up @@ -147,6 +149,60 @@ describe("getLocalEnvironmentBootstraps", () => {
});
});

describe("setWindowButtonVisibility", () => {
it.effect("hides and restores the requesting main window's buttons on macOS", () => {
const setVisibility = vi.fn();
const window = {
webContents: { id: 42 },
setWindowButtonVisibility: setVisibility,
} as unknown as Electron.BrowserWindow;

return Effect.gen(function* () {
yield* setWindowButtonVisibility.handler(false, { sender: { id: 42 } });
yield* setWindowButtonVisibility.handler(true, { sender: { id: 42 } });
assert.deepEqual(setVisibility.mock.calls, [[false], [true]]);
}).pipe(
Effect.provideService(HostProcessPlatform, "darwin"),
Effect.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
currentMainOrFirst: Effect.succeed(Option.some(window)),
}),
),
);
});

for (const platform of ["linux", "win32"] as const) {
it.effect(`leaves native buttons alone on ${platform}`, () =>
setWindowButtonVisibility
.handler(false, { sender: { id: 42 } })
.pipe(
Effect.provideService(HostProcessPlatform, platform),
Effect.provide(Layer.mock(ElectronWindow.ElectronWindow)({})),
),
);
}

it.effect("ignores requests from other windows and a missing main window", () => {
const setVisibility = vi.fn();
const window = {
webContents: { id: 42 },
setWindowButtonVisibility: setVisibility,
} as unknown as Electron.BrowserWindow;
return Effect.gen(function* () {
for (const currentWindow of [Option.some(window), Option.none()]) {
yield* setWindowButtonVisibility.handler(false, { sender: { id: 7 } }).pipe(
Effect.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
currentMainOrFirst: Effect.succeed(currentWindow),
}),
),
);
}
assert.deepEqual(setVisibility.mock.calls, []);
}).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
});
});

describe("getWindowFullscreenState", () => {
it.effect("reads the current native window state", () => {
const window = { isFullScreen: () => true } as Electron.BrowserWindow;
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type PickedThemeFile,
} from "@t3tools/contracts";
import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isCommandAvailable } from "@t3tools/shared/shell";
import * as NodeOS from "node:os";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -79,6 +80,19 @@ export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({
}),
});

export const setWindowButtonVisibility = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SET_WINDOW_BUTTON_VISIBILITY_CHANNEL,
payload: Schema.Boolean,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.window.setWindowButtonVisibility")(function* (visible, event) {
if ((yield* HostProcessPlatform) !== "darwin") return;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const window = yield* electronWindow.currentMainOrFirst;
if (Option.isNone(window) || window.value.webContents.id !== event?.sender.id) return;
window.value.setWindowButtonVisibility(visible);
}),
});

export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
result: Schema.Boolean,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
};
},
setWindowButtonVisibility: (visible) =>
ipcRenderer.invoke(IpcChannels.SET_WINDOW_BUTTON_VISIBILITY_CHANNEL, visible),
getWindowFullscreenState: () =>
ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
onWindowFullscreenStateChange: (listener) => {
Expand Down
48 changes: 37 additions & 11 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ import {
} from "./ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";

function subscribeToViewportWidth(onChange: () => void): () => void {
window.addEventListener("resize", onChange);
return () => window.removeEventListener("resize", onChange);
Expand All @@ -73,10 +71,31 @@ function readInitialThreadSidebarWidth(): number {
}
}

function SidebarControl() {
function SidebarControl({
nativeWindowButtons,
isWindowFullscreen,
}: {
nativeWindowButtons: boolean;
isWindowFullscreen: boolean;
}) {
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { toggleSidebar } = useSidebar();
const { toggleSidebar, state, isMobile } = useSidebar();
const compactSidebarEnabled = useCompactSidebarEnabled();
const compact = compactSidebarEnabled && state === "collapsed" && !isMobile;

useEffect(() => {
if (!nativeWindowButtons || isWindowFullscreen) return;
void window.desktopBridge?.setWindowButtonVisibility?.(!compact).catch(console.error);
}, [nativeWindowButtons, compact, isWindowFullscreen]);

useEffect(() => {
if (!nativeWindowButtons) return;
return () => {
void window.desktopBridge?.setWindowButtonVisibility?.(true).catch(console.error);
};
}, [nativeWindowButtons]);
const isSidebarVisible = useSidebarVisibility();
const isControlOnSidebar = isSidebarVisible || (nativeWindowButtons && compact);
const environmentIdentificationMode = useEnvironmentIdentificationMode();
const stageBackdropVariant = useSidebarStageBackdropVariant(
environmentIdentificationMode === "artwork",
Expand Down Expand Up @@ -118,10 +137,10 @@ function SidebarControl() {
<SidebarTrigger
className={cn(
"pointer-events-auto",
isSidebarVisible &&
isControlOnSidebar &&
stageBackdropVariant &&
"focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white! [:hover,[data-pressed]]:bg-white/15",
isSidebarVisible &&
isControlOnSidebar &&
stageBackdropVariant &&
resolveSidebarStageFocusRingOffsetClass(stageBackdropVariant),
)}
Expand Down Expand Up @@ -158,6 +177,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");
const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
const nativeWindowButtons =
isMacosDesktop && typeof window.desktopBridge?.setWindowButtonVisibility === "function";
const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
// Subscribed rather than read once: the clamp must track live window size,
// and a clamped drag ends with an unchanged width, which skips the re-render
Expand All @@ -181,9 +202,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
const sidebarProviderStyle = {
"--sidebar-width": `${sidebarWidth}px`,
"--panel-animation-duration": `${panelAnimationDurationMs}ms`,
...(isMacosDesktop && !isWindowFullscreen
? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
: {}),
} as CSSProperties;

useEffect(() => {
Expand Down Expand Up @@ -226,7 +244,12 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
return (
<PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}>
<SidebarProvider
className="h-dvh! min-h-0!"
className={cn(
"h-dvh! min-h-0!",
isMacosDesktop && !isWindowFullscreen && "[--workspace-controls-left:90px]",
nativeWindowButtons &&
"md:[&:has([data-side=left][data-collapsible=icon])]:[--workspace-controls-left:calc((var(--sidebar-width-icon)-var(--workspace-titlebar-control-size))/2-1px)]",
Comment thread
shivamhwp marked this conversation as resolved.
)}
data-panel-animations={routePanelAnimationsActive ? "true" : "false"}
defaultOpen
style={sidebarProviderStyle}
Expand Down Expand Up @@ -260,7 +283,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
<SidebarRail onDoubleClick={resetSidebarWidth} />
</Sidebar>
{children}
<SidebarControl />
<SidebarControl
nativeWindowButtons={nativeWindowButtons}
isWindowFullscreen={isWindowFullscreen}
/>
</SidebarProvider>
</PanelAnimationSuppressionProvider>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,8 @@ export interface DesktopBridge {
* them.
*/
onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void;
/** Present when the macOS shell supports hiding its native window buttons. */
setWindowButtonVisibility?: (visible: boolean) => Promise<void>;
getWindowFullscreenState: () => boolean;
onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
getUpdateState: () => Promise<DesktopUpdateState>;
Expand Down
Loading