diff --git a/docs/README.md b/docs/README.md index 8a5d4aece..bf02d1f34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -153,6 +153,29 @@ it as `VITE_API_URL` in the `.env` file (locally) or in the CI environment. | `SENTRY_ORG` | `false` | `true` | `false` | Sentry organization. Used for sourcemap uploads at build-time to enable readable stacktraces. | | `SENTRY_PROJECT` | `false` | `true` | `false` | Sentry project name. Used for sourcemap uploads at build-time to enable readable stacktraces. | +## Developer notes: Simulating OS design variants + +The app renders different window chrome depending on the platform — macOS uses +the system traffic-light buttons and extra left padding, while Windows/Linux +renders custom min/max/close controls. + +To test either layout without switching machines, use the `OsDesign` helper +exposed on `window` in the renderer DevTools console: + +```js +OsDesign.setMac() // macOS layout (traffic-light padding, no custom controls) +OsDesign.setWindows() // Windows/Linux layout (custom min/max/close buttons) +OsDesign.reset() // restore the real platform detection +OsDesign.current() // log the currently active variant +``` + +Each `set*` call writes to `sessionStorage` and reloads the page. The override +is scoped to the current session and does not affect any behavioural logic (file +paths, networking flags, etc.) — only the visual layout. + +> **Tip:** Open DevTools with `Ctrl+Shift+I` (or `Cmd+Option+I` on macOS), then +> run the commands in the Console tab. + ## Developer notes: Using a custom thv binary (dev only) During development, you can test the UI with a custom `thv` binary by running it diff --git a/e2e-tests/secrets.spec.ts b/e2e-tests/secrets.spec.ts index 18444ebce..9d509e5ef 100644 --- a/e2e-tests/secrets.spec.ts +++ b/e2e-tests/secrets.spec.ts @@ -8,15 +8,17 @@ import { test('creates and deletes a secret', async ({ window }) => { deleteTestSecretViaCli() // Clean up leftover from previous failed runs - await window.getByRole('link', { name: 'Secrets' }).click() + await window.getByRole('link', { name: 'Settings' }).click() + await window.getByRole('tab', { name: 'Secrets' }).click() await expect( - window.getByRole('heading', { name: 'Secrets', level: 1 }) + window.getByRole('heading', { name: 'Secrets', level: 2, exact: true }) ).toBeVisible() - await window.getByRole('button', { name: /add.*secret/i }).click() - await window.getByRole('dialog').waitFor() - await window.getByPlaceholder('Name').fill(TEST_SECRET_NAME) - await window.getByPlaceholder('Secret').fill('e2e-test-value') + await window.getByRole('button', { name: /add.*secret|new secret/i }).click() + const dialog = window.getByRole('dialog') + await dialog.waitFor() + await dialog.getByPlaceholder('Name').fill(TEST_SECRET_NAME) + await dialog.getByPlaceholder('Secret').fill('e2e-test-value') await window.getByRole('button', { name: 'Save' }).click() await window.getByRole('dialog').waitFor({ state: 'hidden' }) diff --git a/renderer/src/common/components/help/help-dropdown.tsx b/renderer/src/common/components/help/help-dropdown.tsx index 920fb40e9..f2b415078 100644 --- a/renderer/src/common/components/help/help-dropdown.tsx +++ b/renderer/src/common/components/help/help-dropdown.tsx @@ -5,24 +5,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/common/components/ui/dropdown-menu' -import { Button } from '@/common/components/ui/button' -import { cn } from '@/common/lib/utils' +import { NavIconButton } from '@/common/components/layout/top-nav/nav-icon-button' export function HelpDropdown({ className }: { className?: string }) { return ( - + + + diff --git a/renderer/src/common/components/layout/top-nav/container.tsx b/renderer/src/common/components/layout/top-nav/container.tsx index 9d29d0515..210dacdac 100644 --- a/renderer/src/common/components/layout/top-nav/container.tsx +++ b/renderer/src/common/components/layout/top-nav/container.tsx @@ -1,30 +1,33 @@ import type { HTMLProps } from 'react' -import { twMerge } from 'tailwind-merge' +import { cn } from '@/common/lib/utils' +import { getOsDesignVariant } from '@/common/lib/os-design' function getPlatformSpecificHeaderClasses() { - const platformClasses = { - darwin: 'pl-26 pt-0.5', // Left padding for traffic light buttons + top offset for title bar - win32: 'pr-2', // Right padding for visual spacing with window edge - linux: '', // No padding needed - custom controls are part of the layout + if (getOsDesignVariant() === 'mac') { + // Left padding to clear the macOS traffic-light buttons + return 'pl-26 pr-4' } - return ( - platformClasses[ - window.electronAPI.platform as keyof typeof platformClasses - ] || '' - ) + // Windows needs a small right padding for visual spacing against the window + // edge. This is the only known design difference between Windows and Linux — + // both share the same 'windows' OS design variant for everything else. + if (window.electronAPI.platform === 'win32') { + return 'pr-2' + } + + return '' } export function TopNavContainer(props: HTMLProps) { return (
- - - {badge} - + {children} ) } -function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) { +function useIsActive() { const pathname = useRouterState({ select: (s) => s.location.pathname }) - - const isActive = (paths: string[]) => + return (paths: string[]) => paths.some((p) => pathname.startsWith(p) || pathname === p) +} - const updateBadge = showUpdateBadge ? ( - - - - ) : null +function TopNavLinks() { + const isActive = useIsActive() return ( @@ -101,28 +90,6 @@ function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) { Playground - - - Secrets - - - - - Settings - - - - - ) @@ -131,6 +98,8 @@ function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) { export function TopNav(props: HTMLProps) { const { data: appVersion } = useAppVersion() const isProduction = import.meta.env.MODE === 'production' + const isActive = useIsActive() + const showUpdateBadge = !!(appVersion?.isNewVersionAvailable && isProduction) useEffect(() => { const cleanup = window.electronAPI.onUpdateDownloaded(() => { @@ -163,13 +132,31 @@ export function TopNav(props: HTMLProps) { return (
- +
-
+
+
+ + + + + {showUpdateBadge && ( + + + + )} + + +
+ {/* Windows: separator between icon group and window controls */} + {getOsDesignVariant() !== 'mac' && }
diff --git a/renderer/src/common/components/layout/top-nav/nav-icon-button.tsx b/renderer/src/common/components/layout/top-nav/nav-icon-button.tsx new file mode 100644 index 000000000..0fc5c5419 --- /dev/null +++ b/renderer/src/common/components/layout/top-nav/nav-icon-button.tsx @@ -0,0 +1,27 @@ +import { Button } from '@/common/components/ui/button' +import { cn } from '@/common/lib/utils' +import type { ComponentPropsWithoutRef } from 'react' + +interface NavIconButtonProps extends ComponentPropsWithoutRef { + isActive?: boolean +} + +export function NavIconButton({ + isActive, + className, + ...props +}: NavIconButtonProps) { + return ( + - )} - - - {keys.length === 0 ? ( - { - setIsSecretDialogOpen(true) - setSecretKey('') - }} - > - Add your first Secret - , - ]} - illustration={IllustrationNoConnection} - /> +
) : ( - + ) } diff --git a/renderer/src/common/components/settings/tabs/settings-tabs.tsx b/renderer/src/common/components/settings/tabs/settings-tabs.tsx index 126e6608c..da6ea30ee 100644 --- a/renderer/src/common/components/settings/tabs/settings-tabs.tsx +++ b/renderer/src/common/components/settings/tabs/settings-tabs.tsx @@ -5,6 +5,7 @@ import { VersionTab } from './version-tab' import { LogsTab } from './logs-tab' import { CliTab } from './cli-tab' import { RegistryTab } from '../registry/registry-tab' +import { SecretsTab } from './secrets-tab' import { useAppVersion } from '@/common/hooks/use-app-version' import { ArrowUpCircle, @@ -14,14 +15,16 @@ import { ListEnd, type LucideIcon, Command, + Lock, } from 'lucide-react' -type Tab = 'general' | 'registry' | 'cli' | 'version' | 'logs' +type Tab = 'general' | 'registry' | 'secrets' | 'cli' | 'version' | 'logs' type TabItem = { label: string; value: Tab; icon: LucideIcon } const TABS: TabItem[] = [ { label: 'General', value: 'general', icon: Wrench }, { label: 'Registry', value: 'registry', icon: CloudDownload }, + { label: 'Secrets', value: 'secrets', icon: Lock }, { label: 'CLI', value: 'cli', icon: Command }, { label: 'Version', value: 'version', icon: AppWindow }, { label: 'Logs', value: 'logs', icon: ListEnd }, @@ -82,6 +85,10 @@ export function SettingsTabs({ defaultTab }: SettingsTabsProps) { + + + + diff --git a/renderer/src/common/lib/os-design.ts b/renderer/src/common/lib/os-design.ts new file mode 100644 index 000000000..67aebf5c7 --- /dev/null +++ b/renderer/src/common/lib/os-design.ts @@ -0,0 +1,57 @@ +/** + * OS design variant — controls purely visual/layout decisions (window chrome, + * traffic-light padding, etc.) and is intentionally separate from the real + * platform value used for behavioural logic (log paths, networking flags, …). + * + * In DevTools: + * OsDesign.setMac() – macOS layout (no custom controls, traffic-light padding) + * OsDesign.setWindows() – Windows/Linux layout (custom min/max/close buttons) + * OsDesign.reset() – restore the real platform + * OsDesign.current() – log the active variant + */ + +const STORAGE_KEY = '__thv_os_design_variant' + +export type OsDesignVariant = 'mac' | 'windows' + +function detectVariant(): OsDesignVariant { + const stored = sessionStorage.getItem(STORAGE_KEY) + if (stored === 'mac' || stored === 'windows') return stored + return window.electronAPI.isMac ? 'mac' : 'windows' +} + +export function getOsDesignVariant(): OsDesignVariant { + return detectVariant() +} + +// ── DevTools helper ──────────────────────────────────────────────────────── + +const osDesignDevtools = { + setMac: () => { + sessionStorage.setItem(STORAGE_KEY, 'mac') + location.reload() + }, + setWindows: () => { + sessionStorage.setItem(STORAGE_KEY, 'windows') + location.reload() + }, + reset: () => { + sessionStorage.removeItem(STORAGE_KEY) + location.reload() + }, + current: () => { + const variant = detectVariant() + const isOverridden = sessionStorage.getItem(STORAGE_KEY) !== null + console.log( + `OsDesign variant: "${variant}"${isOverridden ? ' (overridden)' : ''}` + ) + }, +} + +declare global { + interface Window { + OsDesign: typeof osDesignDevtools + } +} + +window.OsDesign = osDesignDevtools diff --git a/renderer/src/features/secrets/components/secrets-table.tsx b/renderer/src/features/secrets/components/secrets-table.tsx index 7de9a47cf..4d72bfab2 100644 --- a/renderer/src/features/secrets/components/secrets-table.tsx +++ b/renderer/src/features/secrets/components/secrets-table.tsx @@ -10,6 +10,9 @@ import { SecretDropdown } from './secret-dropdown' import { useFilterSort } from '@/common/hooks/use-filter-sort' import type { V1SecretKeyResponse } from '@common/api/generated/types.gen' import { InputSearch } from '@/common/components/ui/input-search' +import { Button } from '@/common/components/ui/button' +import { PlusIcon } from 'lucide-react' + interface SecretsTableProps { secrets: V1SecretKeyResponse[] setIsSecretDialogOpen: (open: boolean) => void @@ -33,11 +36,23 @@ export function SecretsTable({ return (
- setFilter(v)} - placeholder="Search..." - /> +
+ setFilter(v)} + placeholder="Search secrets" + /> + +
diff --git a/renderer/src/features/secrets/hooks/use-mutation-create-secret.ts b/renderer/src/features/secrets/hooks/use-mutation-create-secret.ts index a0a678e32..8baa65aa7 100644 --- a/renderer/src/features/secrets/hooks/use-mutation-create-secret.ts +++ b/renderer/src/features/secrets/hooks/use-mutation-create-secret.ts @@ -5,7 +5,7 @@ import { import { useToastMutation } from '@/common/hooks/use-toast-mutation' import { useQueryClient } from '@tanstack/react-query' -export function useMutationCerateSecret() { +export function useMutationCreateSecret() { const queryClient = useQueryClient() return useToastMutation({ ...postApiV1BetaSecretsDefaultKeysMutation(), diff --git a/renderer/src/renderer.tsx b/renderer/src/renderer.tsx index 5ff071ef3..074d0d812 100644 --- a/renderer/src/renderer.tsx +++ b/renderer/src/renderer.tsx @@ -20,6 +20,8 @@ import { trackPageView } from './common/lib/analytics' import { queryClient } from './common/lib/query-client' // Import feature flags to bind them to window for developer tools access import './common/lib/feature-flags' +// Import OS design devtools to bind OsDesign.setMac/setWindows/reset to window +import './common/lib/os-design' // Sentry setup Sentry.init({ diff --git a/renderer/src/route-tree.gen.ts b/renderer/src/route-tree.gen.ts index f99dd0dc5..b7f2073d7 100644 --- a/renderer/src/route-tree.gen.ts +++ b/renderer/src/route-tree.gen.ts @@ -11,7 +11,6 @@ import { Route as rootRouteImport } from "./routes/__root" import { Route as ShutdownRouteImport } from "./routes/shutdown" import { Route as SettingsRouteImport } from "./routes/settings" -import { Route as SecretsRouteImport } from "./routes/secrets" import { Route as PlaygroundRouteImport } from "./routes/playground" import { Route as McpOptimizerRouteImport } from "./routes/mcp-optimizer" import { Route as CliIssueRouteImport } from "./routes/cli-issue" @@ -33,11 +32,6 @@ const SettingsRoute = SettingsRouteImport.update({ path: "/settings", getParentRoute: () => rootRouteImport, } as any) -const SecretsRoute = SecretsRouteImport.update({ - id: "/secrets", - path: "/secrets", - getParentRoute: () => rootRouteImport, -} as any) const PlaygroundRoute = PlaygroundRouteImport.update({ id: "/playground", path: "/playground", @@ -96,7 +90,6 @@ export interface FileRoutesByFullPath { "/cli-issue": typeof CliIssueRoute "/mcp-optimizer": typeof McpOptimizerRoute "/playground": typeof PlaygroundRoute - "/secrets": typeof SecretsRoute "/settings": typeof SettingsRoute "/shutdown": typeof ShutdownRoute "/registry": typeof registryRegistryRoute @@ -111,7 +104,6 @@ export interface FileRoutesByTo { "/cli-issue": typeof CliIssueRoute "/mcp-optimizer": typeof McpOptimizerRoute "/playground": typeof PlaygroundRoute - "/secrets": typeof SecretsRoute "/settings": typeof SettingsRoute "/shutdown": typeof ShutdownRoute "/registry": typeof registryRegistryRoute @@ -127,7 +119,6 @@ export interface FileRoutesById { "/cli-issue": typeof CliIssueRoute "/mcp-optimizer": typeof McpOptimizerRoute "/playground": typeof PlaygroundRoute - "/secrets": typeof SecretsRoute "/settings": typeof SettingsRoute "/shutdown": typeof ShutdownRoute "/(registry)/registry": typeof registryRegistryRoute @@ -144,7 +135,6 @@ export interface FileRouteTypes { | "/cli-issue" | "/mcp-optimizer" | "/playground" - | "/secrets" | "/settings" | "/shutdown" | "/registry" @@ -159,7 +149,6 @@ export interface FileRouteTypes { | "/cli-issue" | "/mcp-optimizer" | "/playground" - | "/secrets" | "/settings" | "/shutdown" | "/registry" @@ -174,7 +163,6 @@ export interface FileRouteTypes { | "/cli-issue" | "/mcp-optimizer" | "/playground" - | "/secrets" | "/settings" | "/shutdown" | "/(registry)/registry" @@ -190,7 +178,6 @@ export interface RootRouteChildren { CliIssueRoute: typeof CliIssueRoute McpOptimizerRoute: typeof McpOptimizerRoute PlaygroundRoute: typeof PlaygroundRoute - SecretsRoute: typeof SecretsRoute SettingsRoute: typeof SettingsRoute ShutdownRoute: typeof ShutdownRoute registryRegistryRoute: typeof registryRegistryRoute @@ -217,13 +204,6 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } - "/secrets": { - id: "/secrets" - path: "/secrets" - fullPath: "/secrets" - preLoaderRoute: typeof SecretsRouteImport - parentRoute: typeof rootRouteImport - } "/playground": { id: "/playground" path: "/playground" @@ -302,7 +282,6 @@ const rootRouteChildren: RootRouteChildren = { CliIssueRoute: CliIssueRoute, McpOptimizerRoute: McpOptimizerRoute, PlaygroundRoute: PlaygroundRoute, - SecretsRoute: SecretsRoute, SettingsRoute: SettingsRoute, ShutdownRoute: ShutdownRoute, registryRegistryRoute: registryRegistryRoute, diff --git a/renderer/src/routes/settings.tsx b/renderer/src/routes/settings.tsx index 7b6de8306..b1497a66c 100644 --- a/renderer/src/routes/settings.tsx +++ b/renderer/src/routes/settings.tsx @@ -1,7 +1,15 @@ import { createFileRoute } from '@tanstack/react-router' import SettingsRouteComponent from './-settings.route' +import { getApiV1BetaSecretsDefaultKeysOptions } from '@common/api/generated/@tanstack/react-query.gen' -const VALID_TABS = ['general', 'registry', 'version', 'logs', 'cli'] as const +const VALID_TABS = [ + 'general', + 'registry', + 'secrets', + 'version', + 'logs', + 'cli', +] as const type SettingsTab = (typeof VALID_TABS)[number] @@ -17,5 +25,9 @@ export const Route = createFileRoute('/settings')({ validateSearch: (search: Record): SettingsSearch => ({ tab: isValidTab(search.tab) ? search.tab : undefined, }), + loader: ({ context: { queryClient } }) => + // prefetchQuery (not ensureQueryData) so a secrets API failure doesn't + // break the entire Settings route when the user is on a different tab. + queryClient.prefetchQuery(getApiV1BetaSecretsDefaultKeysOptions()), component: SettingsRouteComponent, })