From 3bf749184080f04ea4942260decd9bfb323a33db Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Thu, 5 Nov 2026 19:13:20 -0800 Subject: [PATCH] refactor(studio): the settings window loses its enlarge mode and keeps one close button The sheet already fills the window, so enlarging only took it over the title bar and added three window buttons beside its own two. The control, the stored preference, the window buttons it summoned and the drag-region rules that went with it are removed; the board draws no buttons in that corner besides closing. --- desktop/frontend-next/src/actions.ts | 1 - desktop/frontend-next/src/design/settings.css | 2 +- desktop/frontend-next/src/i18n/en_settings.ts | 2 - .../frontend-next/src/i18n/msg/settings.ts | 2 - .../src/state/settingsWide.test.ts | 27 ------- .../frontend-next/src/state/settingsWide.ts | 27 ------- desktop/frontend-next/src/styles/app.css | 2 - .../frontend-next/src/styles/dialogcenter.css | 2 +- .../src/styles/dialogcenter.test.ts | 12 --- .../src/styles/settings-header-drag.test.ts | 16 ---- desktop/frontend-next/src/ui/Settings.tsx | 11 +-- .../ui/settings-actions.interaction.test.tsx | 33 ++++++++ .../ui/settings-expand.interaction.test.tsx | 77 ------------------- tools/repolint/baseline.json | 4 +- 14 files changed, 39 insertions(+), 179 deletions(-) delete mode 100644 desktop/frontend-next/src/state/settingsWide.test.ts delete mode 100644 desktop/frontend-next/src/state/settingsWide.ts delete mode 100644 desktop/frontend-next/src/styles/settings-header-drag.test.ts create mode 100644 desktop/frontend-next/src/ui/settings-actions.interaction.test.tsx delete mode 100644 desktop/frontend-next/src/ui/settings-expand.interaction.test.tsx diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index f604263407..42f061a4ac 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -393,7 +393,6 @@ export const ACTIONS: UIAction[] = [ // the row's own action, so this one never writes anything itself. { id: "tree.navigate", kind: "view", target: "none", proof: "interaction" }, { id: "settings.close", kind: "navigation", target: "none", proof: "interaction" }, - { id: "settings.expand", kind: "view", target: "none", proof: "interaction" }, { id: "pane.activate", kind: "navigation", target: "none", proof: "interaction" }, // How this session is being read. One id across a bar button and a menu row, // because reaching a view from the bar and from the detail menu is one intent diff --git a/desktop/frontend-next/src/design/settings.css b/desktop/frontend-next/src/design/settings.css index 2c322594b1..0fcaa1adb4 100644 --- a/desktop/frontend-next/src/design/settings.css +++ b/desktop/frontend-next/src/design/settings.css @@ -353,7 +353,7 @@ .ustack .utip .r { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font: var(--w-med) var(--fs-micro) var(--ui); } .ustack .utip .r b { color: var(--text); font-weight: var(--w-semi); } .prefs { padding: 24px; } -.prefs-sheet:not([data-wide]) { width: 100%; height: 100%; } +.prefs-sheet { width: 100%; height: 100%; } .srow-ctl .unit-field input { width: 72px; } .srow-val { color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--ui); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .srow-val > * { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index d4cd3ce025..00ca8f77f9 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -1372,11 +1372,9 @@ export const EN_SETTINGS: Record = { "已自动归档 {n} 个会话,可在“已归档”中恢复。": "{n} conversations were archived automatically. You can restore them from Archived.", "图标栏": "Icon rail", "导航入口": "Navigation entries", - "放大设置窗口": "Enlarge the settings window", "隐藏共同前缀": "Hide the shared prefix", "{name} 的模型名前缀": "Model name prefix of {name}", "路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。": "The same prefix a routing service puts on every model (such as group/) is left out of how they are drawn. Display only: model ids do not change, an alias you typed wins, and it takes effect on save.", - "缩小设置窗口": "Shrink the settings window", "图标栏和侧栏里直达各页面的入口:可以隐藏,也可以调整先后。会话、反馈、账号和设置始终保留。": "The shortcuts to pages in the icon rail and the sidebar: hide any of them or change their order. Sessions, feedback, account and settings are always kept.", "还原排列": "Reset arrangement", "上移:{name}": "Move up: {name}", diff --git a/desktop/frontend-next/src/i18n/msg/settings.ts b/desktop/frontend-next/src/i18n/msg/settings.ts index 3a36251c64..c574f3c68c 100644 --- a/desktop/frontend-next/src/i18n/msg/settings.ts +++ b/desktop/frontend-next/src/i18n/msg/settings.ts @@ -44,7 +44,6 @@ export const SETTINGS: Messages = { "settings.empty_is_automatic_1_to": { zh: "留空表示自动;可填 1 到 {max}", en: "Empty is automatic; 1 to {max}" }, "settings.empty_means_automatic_a_browser": { zh: "默认留空:自动。浏览器对同一个地址只开约 6 个连接,会话列表占一个,所以只让少数标签保持实时连接,其余的标签在显示时从断点接着读,运行中的后台标签每 5 秒读一次状态。调高会让更多后台标签实时更新,但开太多会让停止、发送排不上队。", en: "Empty means automatic. A browser opens only about six connections to one address and the session list takes one, so only a few tabs stay live; the others resume from where they stopped when shown, and a running background tab has its status read every 5 seconds. A higher number updates more background tabs live, but too many makes Stop and Send queue behind them." }, "settings.english": { zh: "英文", en: "English" }, - "settings.enlarge_the_settings_window": { zh: "放大设置窗口", en: "Enlarge the settings window" }, "settings.environment_variables": { zh: "环境变量", en: "Environment variables" }, "settings.external_tools": { zh: "外部工具", en: "External tools" }, "settings.file_changes": { zh: "文件改动", en: "File changes" }, @@ -149,7 +148,6 @@ export const SETTINGS: Messages = { "settings.show_a_status_icon_in": { zh: "在托盘显示图标", en: "Show a status icon in the tray" }, "settings.show_file_changes_above_the": { zh: "在输入框上方显示文件改动", en: "Show file changes above the input box" }, "settings.show_name": { zh: "显示:{name}", en: "Show: {name}" }, - "settings.shrink_the_settings_window": { zh: "缩小设置窗口", en: "Shrink the settings window" }, "settings.sign_in_status_could_not": { zh: "无法读取登录状态", en: "Sign-in status could not be read" }, "settings.sign_out": { zh: "退出登录", en: "Sign out" }, "settings.signed_in": { zh: "已登录", en: "Signed in" }, diff --git a/desktop/frontend-next/src/state/settingsWide.test.ts b/desktop/frontend-next/src/state/settingsWide.test.ts deleted file mode 100644 index 71635c897d..0000000000 --- a/desktop/frontend-next/src/state/settingsWide.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, expect, it } from "vitest"; -import { onSettingsWideChange, setSettingsWide, settingsWide } from "./settingsWide"; - -beforeEach(() => localStorage.clear()); - -it("is the default size until this machine chooses otherwise", () => { - expect(settingsWide()).toBe(false); -}); - -it("reads back a choice and tells subscribers", () => { - let heard = 0; - const stop = onSettingsWideChange(() => heard++); - setSettingsWide(true); - expect(settingsWide()).toBe(true); - setSettingsWide(false); - stop(); - expect(settingsWide()).toBe(false); - expect(heard).toBe(2); -}); - -it("reads anything unreadable as the default size", () => { - for (const raw of ["", "yes", "1", "{}"]) { - localStorage.setItem("rx-settings-wide", raw); - expect(settingsWide(), raw).toBe(false); - } -}); diff --git a/desktop/frontend-next/src/state/settingsWide.ts b/desktop/frontend-next/src/state/settingsWide.ts deleted file mode 100644 index 8f6be327f8..0000000000 --- a/desktop/frontend-next/src/state/settingsWide.ts +++ /dev/null @@ -1,27 +0,0 @@ -const KEY = "rx-settings-wide"; -const listeners = new Set<() => void>(); - -export function settingsWide(): boolean { - try { - return localStorage.getItem(KEY) === "on"; - } catch { - return false; - } -} - -export function onSettingsWideChange(fn: () => void): () => void { - listeners.add(fn); - return () => { - listeners.delete(fn); - }; -} - -export function setSettingsWide(on: boolean): void { - try { - if (on) localStorage.setItem(KEY, "on"); - else localStorage.removeItem(KEY); - } catch { - /* a private window keeps the default, which is the same answer it gives */ - } - listeners.forEach((fn) => fn()); -} diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index 20b600f543..1702827849 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -2139,8 +2139,6 @@ background: var(--float); box-shadow: var(--shadow-float); animation: m-panel var(--t-panel) var(--ease-spring) both; } - .prefs-sheet[data-wide] { width: 100%; height: 100% } - .prefs-sheet[data-wide] .prefs-main .prefs-col { max-width: min(1200px, 100%); margin-inline: auto } @supports (view-transition-name: none) { diff --git a/desktop/frontend-next/src/styles/dialogcenter.css b/desktop/frontend-next/src/styles/dialogcenter.css index 514b26bb5a..8c9a3556fd 100644 --- a/desktop/frontend-next/src/styles/dialogcenter.css +++ b/desktop/frontend-next/src/styles/dialogcenter.css @@ -1,3 +1,3 @@ :root:not([data-fold~="scene"]) :is(.prefs, .fbk-veil, .image-preview, .cpv, .agent-tx-scrim) { border-bottom: var(--chrome-h, 39px) solid transparent; -} :root:not([data-fold~="scene"]) .prefs:has(> .prefs-sheet[data-wide]) { border-bottom-width: 0; } :root .prefs:has(> .prefs-sheet[data-wide]) { inset: 0; } .prefs-sheet[data-wide] { border-width: 0; border-radius: 0; } [data-titlebar="app"] .prefs-sheet[data-wide] .prefs-hd { -webkit-app-region: drag; } [data-titlebar="app"] .prefs-sheet[data-wide] .prefs-hd :is(button, a[href], input, .winctl) { -webkit-app-region: no-drag; } [data-titlebar="app"][data-platform="darwin"] .prefs-sheet[data-wide] .prefs-hd { padding-left: calc(var(--lights-w, 96px) + 8px); } .prefs-sheet[data-wide] .prefs-hd .winctl { margin-right: -16px; margin-left: 0; } +} diff --git a/desktop/frontend-next/src/styles/dialogcenter.test.ts b/desktop/frontend-next/src/styles/dialogcenter.test.ts index 9cc9c59d7f..fbb8a27636 100644 --- a/desktop/frontend-next/src/styles/dialogcenter.test.ts +++ b/desktop/frontend-next/src/styles/dialogcenter.test.ts @@ -6,15 +6,3 @@ const css = CSS["./dialogcenter.css"]!; it("keeps the transparent bottom border that centres a dialog under the title bar", () => { expect(css).toMatch(/:is\(\.prefs, \.fbk-veil, \.image-preview, \.cpv, \.agent-tx-scrim\)\s*\{\s*border-bottom:\s*var\(--chrome-h, 39px\) solid transparent;/); }); - -it("takes it off a settings window that has been enlarged, so the sheet reaches the bottom of the window", () => { - const rule = css.match(/\.prefs:has\(> \.prefs-sheet\[data-wide\]\)\s*\{([^}]*)\}/)?.[1] ?? ""; - expect(rule).toMatch(/border-bottom-width:\s*0/); -}); - -it("lets an enlarged settings window cover the title bar too, with its header standing in for the bar", () => { - expect(css).toMatch(/\.prefs:has\(> \.prefs-sheet\[data-wide\]\)\s*\{\s*inset:\s*0;/); - expect(css).toMatch(/\.prefs-sheet\[data-wide\]\s*\{\s*border-width:\s*0;\s*border-radius:\s*0;/); - expect(css).toMatch(/\[data-titlebar="app"\] \.prefs-sheet\[data-wide\] \.prefs-hd\s*\{\s*-webkit-app-region:\s*drag;/); - expect(css).toMatch(/\[data-platform="darwin"\] \.prefs-sheet\[data-wide\] \.prefs-hd\s*\{\s*padding-left:\s*calc\(var\(--lights-w, 96px\) \+ 8px\);/); -}); diff --git a/desktop/frontend-next/src/styles/settings-header-drag.test.ts b/desktop/frontend-next/src/styles/settings-header-drag.test.ts deleted file mode 100644 index 6199b14201..0000000000 --- a/desktop/frontend-next/src/styles/settings-header-drag.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, it } from "vitest"; - -const CSS = import.meta.glob("./dialogcenter.css", { query: "?raw", import: "default", eager: true }) as Record; -const css = CSS["./dialogcenter.css"]!; - -// Electron treats everything inside a drag region as part of the title bar, so -// the settings header that takes over the title bar swallows clicks on its own -// buttons and search field unless each is carved back out. -const rulesOf = (re: RegExp) => [...css.matchAll(re)].map((m) => m[1]!).join("\n"); - -it("makes the full-window settings header a drag region and carves its controls back out", () => { - const drag = css.match(/\[data-titlebar="app"\] \.prefs-sheet\[data-wide\] \.prefs-hd\s*\{[^}]*-webkit-app-region:\s*drag/); - expect(drag).not.toBeNull(); - const carved = rulesOf(/\[data-titlebar="app"\] \.prefs-sheet\[data-wide\] \.prefs-hd :is\(([^)]*)\)\s*\{[^}]*-webkit-app-region:\s*no-drag[^}]*\}/g); - for (const control of ["button", "input", ".winctl"]) expect(carved).toContain(control); -}); diff --git a/desktop/frontend-next/src/ui/Settings.tsx b/desktop/frontend-next/src/ui/Settings.tsx index c820295aec..baa07b57e7 100644 --- a/desktop/frontend-next/src/ui/Settings.tsx +++ b/desktop/frontend-next/src/ui/Settings.tsx @@ -1,6 +1,4 @@ -import { WindowControls } from "./WindowControls"; -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { onSettingsWideChange, setSettingsWide, settingsWide } from "../state/settingsWide"; +import { useCallback, useEffect, useRef, useState } from "react"; import { StudioIcon } from "./StudioIcon"; import { useModalFocus } from "./useModalFocus"; import { SettingsLeaveContext, useSettingsLeave } from "./SettingsLeave"; @@ -77,7 +75,6 @@ const SOLO_TITLE: Record = { ext: "ext.extension", usage: "app.u export function Settings({ hub, onError, port, networkPort, localWorkspacePort, networkHost, status, theme, onTheme, contrast, onContrast, weight, onWeight, look, onLook, onClose, onChanged, onSessionsRecovered, reloadThemes, at: opened, account: acct, accountUnread, reloadAccount, workspaceRoot }: Props) { const navigation = useSettingsLeave(); const close = useCallback(() => navigation.leave(onClose), [navigation.leave, onClose]); - const wide = useSyncExternalStore(onSettingsWideChange, settingsWide, settingsWide); const homePort = networkPort ?? port; const localPort = localWorkspacePort ?? homePort; const [openedName, namedAnchor = ""] = (opened ?? "").split(":", 2); @@ -348,7 +345,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort, onMouseUp={(e) => { if (veiled.current && e.target === e.currentTarget && !updatingPkg.applying) close(); }} data-action-mouseup="settings.close" > -
+

{t(solo ? SOLO_TITLE[soloSection] : "common.settings")}

{/* 原来这里挂着一句「改动立刻生效;需要重建运行时的……」。它对三档里的 @@ -374,13 +371,9 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort, }} />
- - {wide && }
{found !== null && (
diff --git a/desktop/frontend-next/src/ui/settings-actions.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-actions.interaction.test.tsx new file mode 100644 index 0000000000..0d30a1b661 --- /dev/null +++ b/desktop/frontend-next/src/ui/settings-actions.interaction.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { Settings } from "./Settings"; +import { MockPort } from "../port/mock"; +import { MockHub } from "../port/mock_hub"; +import type { AgentPort, SessionStatus } from "../port/port"; + +afterEach(cleanup); + +function draw(onClose = vi.fn()) { + render( {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}} + look={{} as never} onLook={() => {}} reloadThemes={() => {}} + onClose={onClose} onChanged={vi.fn()} onError={() => {}} at="appearance" + account={null} accountUnread="" reloadAccount={() => {}} + />); + return onClose; +} + +it("has one control in its corner, the one that closes it", async () => { + const onClose = draw(); + const buttons = document.querySelectorAll(".prefs-actions button"); + expect(buttons).toHaveLength(1); + expect(screen.queryByRole("button", { name: "放大设置窗口" })).toBeNull(); + expect(document.querySelector(".prefs-sheet")?.hasAttribute("data-wide")).toBe(false); + await userEvent.click(buttons[0]!); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); +}); diff --git a/desktop/frontend-next/src/ui/settings-expand.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-expand.interaction.test.tsx deleted file mode 100644 index d214964639..0000000000 --- a/desktop/frontend-next/src/ui/settings-expand.interaction.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import "./testkit"; -import { Settings } from "./Settings"; -import { MockPort } from "../port/mock"; -import { MockHub } from "../port/mock_hub"; -import type { AgentPort, SessionStatus } from "../port/port"; - -beforeEach(() => localStorage.clear()); -afterEach(cleanup); - -function draw(onClose = vi.fn()) { - render( {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}} - look={{} as never} onLook={() => {}} reloadThemes={() => {}} - onClose={onClose} onChanged={vi.fn()} onError={() => {}} at="appearance" - account={null} accountUnread="" reloadAccount={() => {}} - />); - return document.querySelector(".prefs-sheet")!; -} - -it("offers a control that fills the window, and says whether it is on", async () => { - const sheet = draw(); - const expand = screen.getByRole("button", { name: "放大设置窗口" }); - expect(expand.getAttribute("aria-pressed")).toBe("false"); - expect(sheet.hasAttribute("data-wide")).toBe(false); - await userEvent.click(expand); - expect(expand.getAttribute("aria-pressed")).toBe("true"); - expect(sheet.hasAttribute("data-wide")).toBe(true); - await userEvent.click(expand); - expect(sheet.hasAttribute("data-wide")).toBe(false); -}); - -it("opens at the size it was last left at", async () => { - draw(); - await userEvent.click(screen.getByRole("button", { name: "放大设置窗口" })); - cleanup(); - const sheet = draw(); - expect(sheet.hasAttribute("data-wide")).toBe(true); - expect(screen.getByRole("button", { name: "放大设置窗口" }).getAttribute("aria-pressed")).toBe("true"); -}); - -it("draws the arrows outward while the window is small and inward once it is enlarged", async () => { - draw(); - const expand = screen.getByRole("button", { name: "放大设置窗口" }); - const path = () => expand.querySelector("path")?.getAttribute("d"); - const outward = path(); - expect(expand.getAttribute("title")).toBe("放大设置窗口"); - await userEvent.click(expand); - expect(path()).not.toBe(outward); - expect(expand.getAttribute("title")).toBe("缩小设置窗口"); - await userEvent.click(expand); - expect(path()).toBe(outward); -}); - -it("keeps search Escape and dismissal available after expanding and restoring", async () => { - const onClose = vi.fn(); - const sheet = draw(onClose); - const expand = screen.getByRole("button", { name: "放大设置窗口" }); - await userEvent.click(expand); - const search = screen.getByRole("textbox", { name: "搜索设置" }); - await userEvent.type(search, "模型"); - await userEvent.keyboard("{Escape}"); - expect(search.value).toBe(""); - expect(onClose).not.toHaveBeenCalled(); - expect(sheet.hasAttribute("data-wide")).toBe(true); - await userEvent.click(expand); - expect(sheet.hasAttribute("data-wide")).toBe(false); - await userEvent.click(screen.getByRole("button", { name: /关闭.*Esc/ })); - expect(onClose).toHaveBeenCalledTimes(1); - await userEvent.keyboard("{Escape}"); - expect(onClose).toHaveBeenCalledTimes(2); -}); diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index fee54642ac..1bcadbf69d 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -7,7 +7,7 @@ "commented-code": 0, "complexity": 666, "css-important": 127, - "css-size": 8878, + "css-size": 8876, "doc-language": 0, "doc-owner": 27, "doc-prose": 1434, @@ -78,7 +78,7 @@ }, "desktop/frontend-next/src/styles/app.css": { "css-important": 24, - "css-size": 5260 + "css-size": 5258 }, "desktop/frontend-next/src/styles/studio.css": { "css-important": 103,