mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Settings: one shared page layout with a fixed gap under the header
Every settings tab in Yaak and Yaak Proxy now renders its heading, description, optional banner and content through SettingsPage, which puts the same 1.5rem gap between each. The banner slot hides itself when nothing inside it has content, so Yaak's General tab no longer gets an accidental gap from the empty commercial-use banner, and the proxy's General tab no longer sits tight under its description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc09688bc9
commit
17b1623e83
11 files changed
+174
-133
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
@@ -14,12 +14,7 @@ import {
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { Link } from "../core/Link";
|
||||
import {
|
||||
SettingCommand,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
|
||||
import { DeprecatedPluginBanner } from "./SettingsPlugins";
|
||||
|
||||
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
|
||||
@@ -56,13 +51,10 @@ export function SettingsAgents() {
|
||||
const cli = cliSetup(client);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Agents</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Connect AI coding agents like Claude Code and Cursor to Yaak.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsPage
|
||||
title="Agents"
|
||||
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
|
||||
>
|
||||
<SettingsList>
|
||||
<SettingsSection title={null}>
|
||||
<div className="pb-4 border-b border-border-subtle">
|
||||
@@ -134,6 +126,6 @@ export function SettingsAgents() {
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ClientCertificate } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, InlineCode, SettingsPage, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRef } from "react";
|
||||
import { showConfirmDelete } from "../../lib/confirm";
|
||||
@@ -218,24 +218,19 @@ export function SettingsCertificates() {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<div className="mb-3">
|
||||
<HStack justifyContent="between" alignItems="start">
|
||||
<div>
|
||||
<Heading>Client Certificates</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Add and manage TLS certificates on a per domain basis
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
||||
|
||||
{certificates.length > 0 && (
|
||||
<SettingsPage
|
||||
title="Client Certificates"
|
||||
description="Add and manage TLS certificates on a per domain basis"
|
||||
actions={
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
}
|
||||
banner={
|
||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
||||
}
|
||||
>
|
||||
{certificates.length > 0 ? (
|
||||
<VStack space={3}>
|
||||
{certificates.map((cert, index) => (
|
||||
<CertificateEditor
|
||||
@@ -248,7 +243,7 @@ export function SettingsCertificates() {
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
) : null}
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
SettingsPage,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -94,59 +94,58 @@ export function SettingsHotkeys() {
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Keyboard Shortcuts</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
<SettingsPage
|
||||
title="Keyboard Shortcuts"
|
||||
description="Click the menu button to add, remove, or reset keyboard shortcuts."
|
||||
>
|
||||
<VStack space={3}>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { InlineCode, SettingsPage } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import {
|
||||
@@ -26,15 +26,11 @@ export function SettingsProxy() {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Proxy</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
||||
traffic, or routing through specific infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
|
||||
<SettingsPage
|
||||
title="Proxy"
|
||||
description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
|
||||
banner={<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />}
|
||||
>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Proxy">
|
||||
<SettingRowSelect
|
||||
@@ -160,7 +156,7 @@ export function SettingsProxy() {
|
||||
</>
|
||||
)}
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogFooter,
|
||||
Heading,
|
||||
Icon,
|
||||
RadioDropdown,
|
||||
SettingRow,
|
||||
@@ -15,8 +14,8 @@ import {
|
||||
SettingSelectControl,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { useState } from "react";
|
||||
@@ -59,11 +58,7 @@ export function SettingsProxy() {
|
||||
const elsewhere = proxyState?.otherOwner != null;
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Proxy</Heading>
|
||||
<p className="text-text-subtle">Listening, decryption, traffic and terminals</p>
|
||||
</div>
|
||||
<SettingsPage title="Proxy" description="Listening, decryption, traffic and terminals">
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Proxy">
|
||||
<PortRow disabled={elsewhere} />
|
||||
@@ -108,7 +103,7 @@ export function SettingsProxy() {
|
||||
<TerminalRow />
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Heading } from "../Heading";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { VStack } from "../Stacks";
|
||||
import {
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
@@ -11,6 +9,7 @@ import {
|
||||
SettingsSection,
|
||||
SettingValue,
|
||||
} from "./SettingRow";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import type { SettingsCallbacks, SettingsView } from "./types";
|
||||
|
||||
export interface SettingsGeneralProps {
|
||||
@@ -25,12 +24,11 @@ export interface SettingsGeneralProps {
|
||||
export function SettingsGeneral({ view, callbacks, banner, extraSections }: SettingsGeneralProps) {
|
||||
const { app, values } = view;
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div>
|
||||
<Heading>General</Heading>
|
||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
||||
</div>
|
||||
{banner != null && <div className="mt-3 mb-5">{banner}</div>}
|
||||
<SettingsPage
|
||||
title="General"
|
||||
description="Configure general settings for update behavior and more."
|
||||
banner={banner}
|
||||
>
|
||||
<SettingsList className="space-y-8">
|
||||
{view.updates != null && <SettingsUpdatesSection view={view} callbacks={callbacks} />}
|
||||
|
||||
@@ -85,7 +83,7 @@ export function SettingsGeneral({ view, callbacks, banner, extraSections }: Sett
|
||||
</SettingRow>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useState } from "react";
|
||||
import { clamp } from "../../lib/clamp";
|
||||
import { Button } from "../Button";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { Heading } from "../Heading";
|
||||
import { VStack } from "../Stacks";
|
||||
import {
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
@@ -13,6 +11,7 @@ import {
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "./SettingRow";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import type {
|
||||
SettingsCallbacks,
|
||||
SettingsEditorKeymap,
|
||||
@@ -52,11 +51,7 @@ export function SettingsInterface({
|
||||
const shown = (row: SettingsInterfaceRow) => !view.hiddenInterfaceRows?.includes(row);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Interface</Heading>
|
||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
||||
</div>
|
||||
<SettingsPage title="Interface" description="Tweak settings related to the user interface.">
|
||||
<SettingsList className="space-y-8">
|
||||
{leadingSections}
|
||||
|
||||
@@ -183,7 +178,7 @@ export function SettingsInterface({
|
||||
|
||||
{trailingSections}
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Heading } from "../Heading";
|
||||
|
||||
export interface SettingsPageProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Beside the heading, like the client's Add Certificate button */
|
||||
actions?: ReactNode;
|
||||
/** Between the header and the content, like the client's commercial-use banner */
|
||||
banner?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settings tab: its heading and description, then an optional banner, then its content, each
|
||||
* the same fixed distance below the last. A banner that renders nothing takes no space, so the
|
||||
* content sits where it would without one.
|
||||
*/
|
||||
export function SettingsPage({ title, description, actions, banner, children }: SettingsPageProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<Heading>{title}</Heading>
|
||||
{description != null && <p className="text-text-subtle">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{banner != null && (
|
||||
// Hidden when nothing inside has content: a banner that renders null, or only an empty
|
||||
// wrapper (the client's commercial-use banner once dismissed)
|
||||
<div className="mt-6 [&:not(:has(:not(:empty)))]:hidden">{banner}</div>
|
||||
)}
|
||||
{children != null && <div className="mt-6">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { Suspense } from "react";
|
||||
import type { ButtonProps } from "../Button";
|
||||
import { useEditorHost } from "../EditorHost";
|
||||
import { ExternalLink } from "../ExternalLink";
|
||||
import { Heading } from "../Heading";
|
||||
import { Icon, type IconProps } from "../Icon";
|
||||
import { IconButton } from "../IconButton";
|
||||
import type { SelectProps } from "../Select";
|
||||
import { HStack, VStack } from "../Stacks";
|
||||
import { SettingRowSelect, SettingsList, SettingsSection } from "./SettingRow";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import type { SettingsCallbacks, SettingsView } from "./types";
|
||||
|
||||
const buttonColors: ButtonProps["color"][] = [
|
||||
@@ -67,10 +67,10 @@ export function SettingsTheme({ view, callbacks }: SettingsThemeProps) {
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Theme</Heading>
|
||||
<p className="text-text-subtle">
|
||||
<SettingsPage
|
||||
title="Theme"
|
||||
description={
|
||||
<>
|
||||
{/* One text node, so the name kerns with the words around it */}
|
||||
{`Make ${app.name} your own by selecting a theme, or`}{" "}
|
||||
<ExternalLink
|
||||
@@ -79,8 +79,9 @@ export function SettingsTheme({ view, callbacks }: SettingsThemeProps) {
|
||||
>
|
||||
Create Your Own
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Theme">
|
||||
<SettingRowSelect
|
||||
@@ -168,6 +169,6 @@ export function SettingsTheme({ view, callbacks }: SettingsThemeProps) {
|
||||
</VStack>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { SettingsDialog } from "./SettingsDialog";
|
||||
import { SettingsGeneral } from "./SettingsGeneral";
|
||||
import { SettingsInterface } from "./SettingsInterface";
|
||||
import { SettingsLicense } from "./SettingsLicense";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import { SettingsTheme } from "./SettingsTheme";
|
||||
import type { SettingsCallbacks, SettingsLicenseStatus, SettingsView } from "./types";
|
||||
|
||||
@@ -145,6 +146,36 @@ describe("SettingsGeneral", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
function NoBanner() {
|
||||
return null;
|
||||
}
|
||||
|
||||
test("puts a banner between the header and the content, each the same gap apart", () => {
|
||||
const html = render(
|
||||
<SettingsPage title="General" description="About it" banner={<p>Banner</p>}>
|
||||
<p>Content</p>
|
||||
</SettingsPage>,
|
||||
);
|
||||
expect(html).toMatch(/<div class="mt-6 [^"]*:hidden"><p>Banner<\/p><\/div>/);
|
||||
expect(html).toContain('<div class="mt-6"><p>Content</p></div>');
|
||||
expect(text(html)).toBe("General About it Banner Content");
|
||||
});
|
||||
|
||||
test("hides the banner slot when the banner renders nothing or only an empty wrapper", () => {
|
||||
for (const banner of [<NoBanner key="null" />, <div key="wrapper" className="w-full" />]) {
|
||||
const html = render(
|
||||
<SettingsPage title="General" banner={banner}>
|
||||
<p>Content</p>
|
||||
</SettingsPage>,
|
||||
);
|
||||
// The slot hides itself when nothing inside it has content
|
||||
expect(html).toMatch(/<div class="mt-6 \[&:not\(:has\(:not\(:empty\)\)\)\]:hidden">/);
|
||||
expect(text(html)).toBe("General Content");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsTheme", () => {
|
||||
test("renders nothing until the themes load", () => {
|
||||
expect(render(<SettingsTheme view={stubView({ themes: null })} callbacks={callbacks} />)).toBe(
|
||||
|
||||
@@ -195,6 +195,8 @@ export type { SettingsInterfaceProps } from "./components/settings/SettingsInter
|
||||
export { SettingsInterface } from "./components/settings/SettingsInterface";
|
||||
export type { SettingsLicenseProps } from "./components/settings/SettingsLicense";
|
||||
export { SettingsLicense } from "./components/settings/SettingsLicense";
|
||||
export type { SettingsPageProps } from "./components/settings/SettingsPage";
|
||||
export { SettingsPage } from "./components/settings/SettingsPage";
|
||||
export type { SettingsThemeProps } from "./components/settings/SettingsTheme";
|
||||
export { SettingsTheme } from "./components/settings/SettingsTheme";
|
||||
export * from "./components/settings/types";
|
||||
|
||||
Reference in new issue
Block a user