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
119 changes: 119 additions & 0 deletions src/components/RecordingControlWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { css } from '@emotion/react'
import {
DragHandleDots2Icon,
MinusCircledIcon,
StopIcon,
} from '@radix-ui/react-icons'
import { BrowserWindowConstructorOptions } from 'electron'
import { useEffect, useState } from 'react'

import { stopRecording } from '@/views/Recorder/Recorder.utils'
import { RecorderState } from '@/views/Recorder/types'

import { SubWindow } from './SubWindow'
import { Button } from './primitives/Button'

interface RecordingControlWindowProps {
state: RecorderState
}

const windowOptions: BrowserWindowConstructorOptions = {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't find a way to open with at a fixed height, while keeping it centered horizontally (x is required if y is set). I'll try to find a solution in the follow-up PR

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we consider remembering the window position, we can reuse this function to keep track of x and y

k6-studio/src/main.ts

Lines 644 to 645 in f1f035a

async function trackWindowState(browserWindow: BrowserWindow) {
const { width, height, x, y } = browserWindow.getBounds()

width: 320,
height: 32,
alwaysOnTop: true,
resizable: false,
fullscreenable: false,
minimizable: false,
maximizable: false,
frame: false,
title: 'Recording toolbar',
useContentSize: true,
}

export function RecordingControlWindow({ state }: RecordingControlWindowProps) {
const [isOpen, setIsOpen] = useState(false)

const handleStopRecording = () => {
stopRecording()
}

const handleClose = () => {
setIsOpen(false)
}

useEffect(() => {
console.log('RecordingControlWindow state', state)
setIsOpen(state === 'recording')
}, [state])

if (!isOpen) {
return null
}

return (
<SubWindow options={windowOptions} onClose={handleClose}>
<div
css={css`
box-sizing: border-box;
height: 100%;
display: flex;
align-items: center;
user-select: none;
gap: 8px;
padding: 4px 8px;
`}
>
<div
css={css`
app-region: drag;
font-size: var(--studio-font-size-1);

&:before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
margin-right: 4px;
background-color: #f00;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;

@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 1;
}
}
}
`}
>
Recording
</div>
<Button size="1" onClick={handleStopRecording}>
<StopIcon /> Stop
</Button>
<Button size="1" onClick={handleClose}>
<MinusCircledIcon /> Close
</Button>
<div
css={css`
app-region: drag;
display: flex;
justify-content: flex-end;
flex-grow: 1;
`}
>
<DragHandleDots2Icon />
</div>
</div>
</SubWindow>
)
}
61 changes: 61 additions & 0 deletions src/components/SubWindow/SubWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import createCache from '@emotion/cache'
import { CacheProvider, Global } from '@emotion/react'
import { BrowserWindowConstructorOptions } from 'electron'
import { PropsWithChildren, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'

import { globalStyles } from '@/globalStyles'

import { Theme } from '../primitives/Theme'

interface SubWindowProps {
options: BrowserWindowConstructorOptions
onClose?: () => void
}

export function SubWindow({
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach is heavily inspired by https://pietrasiak.com/creating-multi-window-electron-apps-using-react-portals with some omissions (for example, it's currently not possible to watch for changes in window options - not sure if it's needed).

It should make it much easier to work with multiple windows (e.g. render validator in a separate window rather than in a modal).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally don't find the points on why to avoid the default way of implementing multiple windows in electron that strong 🤔 (for example using IPC if needed doesn't see that negative since you already make use of it for main <-> renderer communication)

I guess it's debatable but I wonder if there are more things that are going to bite us eventually if we use this approach for everything outside of the overlay 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally don't find the points on why to avoid the default way of implementing multiple windows in electron that strong 🤔 (for example using IPC if needed doesn't see that negative since you already make use of it for main <-> renderer communication)

I can see a few points:

  • You need a separate entry point for each window as each window is its own React SPA, so you can't create windows ad-hoc
  • The portal approach is more declarative and offers a better DX (in my opinion)
  • The portal approach makes it very easy to render a component tree in a window, which means you can implement detachable panels (validator/request inspector) in a very simple way

What are the benefits of the default way?

Copy link
Collaborator

@allansson allansson Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the benefits of the default way?

Being able to use themes, I suppose. 🤔

But I think this is worth a shot. The DX looks pretty sweet and it's nice that state can be easily synced between windows.

Doing this the "intended" way means that we'd have to open a window, wait for React.createRoot to finish and then sync the state from when the window was docked. And on top of that we need to keep two views in sync.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm certainly missing knowledge here so my comment is directly tied to the points in the blog post, specifically the concern is regarding the caveat section:

There are many other things, and I think I don’t even remember all of those now

Hopefully nothing major comes up if we apply it more broadly

Beside that, I agree that is worth a try 🙌

children,
options,
onClose,
}: PropsWithChildren<SubWindowProps>) {
const [id] = useState(crypto.randomUUID())
const [subWindow] = useState<Window | null>(() =>
window.open('about:blank', '_blank', JSON.stringify({ id, options }))
)

useEffect(() => {
return () => {
if (subWindow) {
subWindow.close()
}
}
}, [subWindow])

useEffect(() => {
return window.studio.ui.onCloseWindow((windowId) => {
if (id !== windowId) {
return
}

onClose?.()
})
}, [onClose, id])

if (subWindow === null) {
return null
}

const subWindowCache = createCache({
key: 'ksix-studio',
container: subWindow.document.head,
})

return createPortal(
<CacheProvider value={subWindowCache}>
<Global styles={globalStyles} />
Comment on lines +54 to +55
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, Radix Themes won't work here (more context in this discussion). For this feature, it's not a big problem - I'll follow @allansson's approach from the browser branch - but it means that we can't easily use it to render request inspector/validator just yet :/

<Theme root={true} includeColors />
{children}
</CacheProvider>,
subWindow.document.body
)
}
1 change: 1 addition & 0 deletions src/components/SubWindow/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SubWindow'
3 changes: 3 additions & 0 deletions src/components/primitives/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { css } from '@emotion/react'
import { ComponentProps, forwardRef } from 'react'

const styles = css`
display: inline-flex;
align-items: center;
gap: var(--studio-spacing-2);
border: none;
border-radius: 4px;
font-size: var(--studio-font-size-2);
Expand Down
9 changes: 7 additions & 2 deletions src/globalStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ export const globalStyles = css`
font-style: normal;
}

.radix-themes {
--default-font-family: 'InterVariable', -apple-system, BlinkMacSystemFont,
:root {
--font-family: 'InterVariable', -apple-system, BlinkMacSystemFont,
'Segoe UI (Custom)', Roboto, 'Helvetica Neue', 'Open Sans (Custom)',
system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
}

.radix-themes {
--default-font-family: var(--font-family);
}

body {
margin: 0;
font-family: var(--font-family);
}

pre {
Expand Down
30 changes: 30 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ const createWindow = async () => {
})

configureApplicationMenu()
attachWindowOpenHandler(mainWindow)
configureWatcher(mainWindow)
wasAppClosedByClient = false

Expand Down Expand Up @@ -687,6 +688,35 @@ function configureWatcher(browserWindow: BrowserWindow) {
})
}

function attachWindowOpenHandler(window: BrowserWindow) {
window.webContents.setWindowOpenHandler(({ url, features }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { id, options } = JSON.parse(features)

app.on('browser-window-created', (_, subWindow) => {
subWindow.on('close', () => {
ipcMain.emit('ui:close-window', id)
})
})

if (url !== 'about:blank') {
return { action: 'deny' }
}

return {
action: 'allow',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
overrideBrowserWindowOptions: {
...options,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
},
},
}
})
}

function getStudioFileFromPath(filePath: string): StudioFile | undefined {
const file = {
displayName: path.parse(filePath).name,
Expand Down
3 changes: 3 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ const ui = {
onToast: (callback: (toast: AddToastPayload) => void) => {
return createListener('ui:toast', callback)
},
onCloseWindow: (callback: (id: string) => void) => {
return createListener('ui:close-window', callback)
},
} as const

const generator = {
Expand Down
1 change: 1 addition & 0 deletions src/store/features/useFeaturesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface FeaturesStore {

const defaultFeatures: Record<Feature, boolean> = {
'dummy-feature': false,
'floating-recording-controls': false,
}

export const useFeaturesStore = create<FeaturesStore>()(
Expand Down
2 changes: 1 addition & 1 deletion src/types/features.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type Feature = 'dummy-feature'
export type Feature = 'dummy-feature' | 'floating-recording-controls'
5 changes: 5 additions & 0 deletions src/views/Recorder/Recorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import log from 'electron-log/renderer'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useBlocker, useNavigate } from 'react-router-dom'

import { Feature } from '@/components/Feature'
import { View } from '@/components/Layout/View'
import { RecordingControlWindow } from '@/components/RecordingControlWindow'
import TextSpinner from '@/components/TextSpinner/TextSpinner'
import { DEFAULT_GROUP_NAME } from '@/constants'
import { useListenBrowserEvent } from '@/hooks/useListenBrowserEvent'
Expand Down Expand Up @@ -250,6 +252,9 @@ export function Recorder() {
onCancel={handleCancelNavigation}
onStopRecording={handleConfirmNavigation}
/>
<Feature feature="floating-recording-controls">
<RecordingControlWindow state={recorderState} />
</Feature>
</View>
</RecordingContext>
)
Expand Down