From 5d8458444afe4f45381ff79301a3310bbe35c4d2 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sat, 31 Oct 2026 16:46:08 +0800 Subject: [PATCH 1/3] feat(workspace): add project resources and customizable icons --- design-system/AGENTS.md | 9 + .../src/i18n/colorPickerMessages.ts | 26 + .../design-lab/src/i18n/componentMetadata.ts | 1 + .../apps/design-lab/src/i18n/messages.ts | 4 + .../src/pages/ComponentDetailPage.tsx | 3 + .../design-lab/src/pages/ComponentsPage.tsx | 2 + .../src/preview/ColorPickerPreview.tsx | 10 + .../src/preview/componentPresentation.ts | 2 + .../vite/component-preview-render.test.mjs | 10 + .../theme-openbitfun/src/dark.tokens.json | 11 + .../theme-openbitfun/src/light.tokens.json | 11 + design-system/packages/ui/README.md | 26 +- .../ColorPicker/ColorPicker.meta.ts | 14 + .../ColorPicker/ColorPicker.module.css | 65 ++ .../components/ColorPicker/ColorPicker.tsx | 190 +++++ .../ui/src/components/ColorPicker/color.ts | 54 ++ .../ui/src/components/ColorPicker/index.ts | 1 + .../ui/src/components/Select/Select.meta.ts | 2 + .../src/components/Select/Select.module.css | 5 + .../ui/src/components/Select/Select.tsx | 16 +- design-system/packages/ui/src/index.ts | 1 + design-system/packages/ui/src/registry.ts | 2 + .../packages/ui/tests/color-picker.test.mjs | 16 + .../packages/ui/tests/registry.test.mjs | 1 + docs/architecture/theme-token-optimization.md | 7 + docs/development/ui-testids-CN.md | 3 +- docs/development/ui-testids.md | 3 +- docs/interactive-capabilities/README.md | 16 +- .../capabilities.json | 68 +- .../capabilities/feature.projects.md | 2 + .../technical/product-control-open-audit.json | 23 +- .../technical/tauri-command-map.json | 40 +- scripts/check-core-boundaries.test.mjs | 19 +- .../cargo-dependency-boundaries.mjs | 3 + .../core-boundaries/rules/feature-rules.mjs | 27 +- src/apps/cli/Cargo.toml | 1 + src/apps/cli/src/peer_host/commands/mod.rs | 4 + .../cli/src/peer_host/commands/workspace.rs | 26 + .../data-tools/ui/generated/design-system.css | 16 + src/apps/desktop/README.md | 43 ++ src/apps/desktop/src/api/commands.rs | 19 + .../desktop/src/generated/bootstrap_theme.css | 8 + src/apps/desktop/src/lib.rs | 2 + src/crates/assembly/core/AGENTS.md | 11 + src/crates/assembly/core/Cargo.toml | 2 + .../core/src/service/workspace/icons.rs | 624 ++++++++++++++++ .../core/src/service/workspace/mod.rs | 2 + .../core/src/service/workspace/service.rs | 20 + .../core/src/service_agent_runtime.rs | 22 + src/crates/contracts/core-types/src/lib.rs | 1 + .../core-types/src/workspace_icon.rs | 194 +++++ .../generated/product-control-catalog.json | 68 +- .../generated/remote-surface-registry.json | 27 +- .../src/remote_surface/capabilities.rs | 4 + .../src/remote_surface/table.rs | 2 + src/crates/contracts/runtime-ports/Cargo.toml | 2 +- .../src/remote_workspace_ports.rs | 12 + src/crates/services/services-core/AGENTS.md | 9 + src/crates/services/services-core/Cargo.toml | 1 + src/crates/services/services-core/src/lib.rs | 2 + .../src/workspace_icon_assets.rs | 153 ++++ .../services/services-integrations/AGENTS.md | 7 + .../src/remote_connect.rs | 157 +++- src/mobile-web/README.md | 15 + .../src/components/WorkspaceIcon.tsx | 51 ++ src/mobile-web/src/i18n/messages.ts | 3 + src/mobile-web/src/pages/SessionListPage.tsx | 9 +- src/mobile-web/src/pages/WorkspacePage.tsx | 25 +- .../src/services/RemoteSessionManager.ts | 29 + .../tests/workspace-identity.test.mjs | 40 ++ .../interactive-capabilities/catalog.json | 15 + src/web-ui/AGENTS.md | 18 + src/web-ui/src/app/appearance.ts | 1 + .../navigationIconIntegration.test.ts | 6 +- .../sections/workspaces/WorkspaceItem.tsx | 92 +-- .../workspaces/WorkspaceListSection.scss | 19 +- .../WorkspaceListSectionLayout.test.ts | 7 +- ...paceProjectPermissionsDialog.appearance.ts | 4 +- .../WorkspaceProjectPermissionsDialog.scss | 197 +----- .../WorkspaceProjectPermissionsDialog.tsx | 511 +++++++------ .../WorkspaceProjectPermissionsPanel.test.tsx | 252 +++++++ .../WorkspaceRelatedPathAddDialog.tsx | 136 ++++ .../WorkspaceRelatedPathsDialog.appearance.ts | 2 +- .../WorkspaceRelatedPathsDialog.scss | 56 +- .../WorkspaceRelatedPathsDialog.test.tsx | 119 +++- .../WorkspaceRelatedPathsDialog.tsx | 169 +++-- .../NavPanel/unifiedSessionCreation.test.ts | 2 +- .../src/app/components/SceneBar/types.ts | 1 + .../WorkspaceIcon/WorkspaceIcon.scss | 87 +++ .../WorkspaceIcon/WorkspaceIcon.test.tsx | 33 + .../WorkspaceIcon/WorkspaceIcon.tsx | 40 ++ .../WorkspaceIcon/WorkspaceIconColors.tsx | 74 ++ .../WorkspaceIconDialog.test.tsx | 232 ++++++ .../WorkspaceIcon/WorkspaceIconDialog.tsx | 129 ++++ .../WorkspaceIcon/WorkspaceIconGlyphs.tsx | 35 + .../components/WorkspaceIcon/appearance.ts | 7 + .../WorkspaceIcon/prepareWorkspaceIcon.ts | 19 + .../useWorkspaceIconImageDraft.ts | 32 + .../scheduled-jobs/LocalizedDateTimeField.tsx | 6 + .../scheduled-jobs/ScheduledJobsModal.scss | 4 + .../scheduled-jobs/ScheduledJobsModal.tsx | 17 + .../scheduled-jobs/ScheduledJobsView.scss | 26 + .../ScheduledJobsView.table.test.tsx | 177 +++++ .../scheduled-jobs/ScheduledJobsView.tsx | 669 ++++++++++-------- .../generated/interactive-capabilities.json | 68 +- src/web-ui/src/app/hooks/useSceneManager.ts | 14 +- src/web-ui/src/app/scenes/SceneViewport.tsx | 6 +- .../components/CreateAgentDialog.test.tsx | 12 + .../agents/components/CreateAgentDialog.tsx | 11 +- .../agents/components/CreateAgentPage.tsx | 17 +- .../ProjectDirectoriesSection.test.tsx | 130 ++++ .../ProjectDirectoriesSection.tsx | 74 ++ .../ProjectResourceCards.scss | 86 +++ .../ProjectResourceCards.tsx | 62 ++ .../ProjectResourceList.scss | 48 ++ .../ProjectResourceList.test.tsx | 64 ++ .../project-resources/ProjectResourceList.tsx | 79 +++ .../ProjectResourceMetrics.test.tsx | 87 +++ .../ProjectResourceMetrics.tsx | 54 ++ .../ProjectResourceSummary.scss | 47 ++ .../ProjectResourceSummary.tsx | 29 + .../ProjectResourcesScene.scss | 149 ++++ .../ProjectResourcesScene.test.tsx | 214 ++++++ .../ProjectResourcesScene.tsx | 244 +++++++ .../ProjectScheduledJobList.tsx | 18 + .../app/scenes/project-resources/README.md | 134 ++++ .../scenes/project-resources/appearance.ts | 6 + .../projectResourceStats.test.ts | 43 ++ .../project-resources/projectResourceStats.ts | 23 + .../projectResourceTarget.ts | 23 + .../projectResources.test.ts | 11 + .../useProjectDirectoryActions.test.tsx | 143 ++++ .../useProjectDirectoryActions.ts | 65 ++ .../useProjectResources.test.tsx | 97 +++ .../project-resources/useProjectResources.ts | 71 ++ src/web-ui/src/app/scenes/registry.ts | 5 + .../components/ShellNavWorkspaceSwitcher.tsx | 10 +- .../skills/components/AddSkillDialog.tsx | 6 +- .../skills/hooks/useInstalledSkills.test.tsx | 28 +- .../scenes/skills/hooks/useInstalledSkills.ts | 17 +- .../startupPerformanceContract.test.ts | 7 +- src/web-ui/src/app/stores/sceneStore.test.ts | 20 + .../api/adapters/peer-device-adapter.ts | 1 + .../api/generated/productControl.ts | 2 +- .../api/generated/remoteSurface.ts | 5 +- .../api/service-api/WorkspaceAPI.test.ts | 7 + .../api/service-api/WorkspaceAPI.ts | 4 +- .../registry/defaultAppearanceRegistry.ts | 5 + .../AppearancePackageConfigSection.test.tsx | 91 +-- .../AppearancePackageConfigSection.tsx | 43 +- .../i18n/presets/namespaceRegistry.ts | 1 + .../peer-device/PeerConnectionManager.ts | 4 + .../workspace-icons/useWorkspaceIcon.ts | 8 + .../workspace-icons/workspaceIcons.test.ts | 122 ++++ .../workspace-icons/workspaceIcons.ts | 127 ++++ src/web-ui/src/locales/en-US/common.json | 65 +- .../en-US/scenes/project-resources.json | 86 +++ .../src/locales/en-US/settings/runtime.json | 14 +- src/web-ui/src/locales/zh-CN/common.json | 65 +- .../zh-CN/scenes/project-resources.json | 86 +++ .../locales/zh-CN/settings/application.json | 6 +- .../src/locales/zh-CN/settings/runtime.json | 14 +- src/web-ui/src/locales/zh-TW/common.json | 65 +- .../zh-TW/scenes/project-resources.json | 86 +++ .../locales/zh-TW/settings/application.json | 4 +- .../src/locales/zh-TW/settings/runtime.json | 14 +- .../src/shared/styles/_resource-table.scss | 49 ++ src/web-ui/src/shared/ui/ColorPicker.test.tsx | 137 ++++ src/web-ui/src/shared/ui/Select.test.tsx | 23 + .../workspace/components/WorkspaceManager.tsx | 17 +- 170 files changed, 7870 insertions(+), 1001 deletions(-) create mode 100644 design-system/apps/design-lab/src/i18n/colorPickerMessages.ts create mode 100644 design-system/apps/design-lab/src/preview/ColorPickerPreview.tsx create mode 100644 design-system/packages/ui/src/components/ColorPicker/ColorPicker.meta.ts create mode 100644 design-system/packages/ui/src/components/ColorPicker/ColorPicker.module.css create mode 100644 design-system/packages/ui/src/components/ColorPicker/ColorPicker.tsx create mode 100644 design-system/packages/ui/src/components/ColorPicker/color.ts create mode 100644 design-system/packages/ui/src/components/ColorPicker/index.ts create mode 100644 design-system/packages/ui/tests/color-picker.test.mjs create mode 100644 src/crates/assembly/core/src/service/workspace/icons.rs create mode 100644 src/crates/contracts/core-types/src/workspace_icon.rs create mode 100644 src/crates/services/services-core/src/workspace_icon_assets.rs create mode 100644 src/mobile-web/src/components/WorkspaceIcon.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsPanel.test.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathAddDialog.tsx create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/WorkspaceIcon.scss create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/WorkspaceIcon.test.tsx create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/WorkspaceIcon.tsx create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/WorkspaceIconColors.tsx create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/WorkspaceIconDialog.test.tsx create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/WorkspaceIconDialog.tsx create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/WorkspaceIconGlyphs.tsx create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/appearance.ts create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/prepareWorkspaceIcon.ts create mode 100644 src/web-ui/src/app/components/WorkspaceIcon/useWorkspaceIconImageDraft.ts create mode 100644 src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.table.test.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectDirectoriesSection.test.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectDirectoriesSection.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceCards.scss create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceCards.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceList.scss create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceList.test.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceList.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceMetrics.test.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceMetrics.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceSummary.scss create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourceSummary.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourcesScene.scss create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourcesScene.test.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectResourcesScene.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/ProjectScheduledJobList.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/README.md create mode 100644 src/web-ui/src/app/scenes/project-resources/appearance.ts create mode 100644 src/web-ui/src/app/scenes/project-resources/projectResourceStats.test.ts create mode 100644 src/web-ui/src/app/scenes/project-resources/projectResourceStats.ts create mode 100644 src/web-ui/src/app/scenes/project-resources/projectResourceTarget.ts create mode 100644 src/web-ui/src/app/scenes/project-resources/projectResources.test.ts create mode 100644 src/web-ui/src/app/scenes/project-resources/useProjectDirectoryActions.test.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/useProjectDirectoryActions.ts create mode 100644 src/web-ui/src/app/scenes/project-resources/useProjectResources.test.tsx create mode 100644 src/web-ui/src/app/scenes/project-resources/useProjectResources.ts create mode 100644 src/web-ui/src/infrastructure/workspace-icons/useWorkspaceIcon.ts create mode 100644 src/web-ui/src/infrastructure/workspace-icons/workspaceIcons.test.ts create mode 100644 src/web-ui/src/infrastructure/workspace-icons/workspaceIcons.ts create mode 100644 src/web-ui/src/locales/en-US/scenes/project-resources.json create mode 100644 src/web-ui/src/locales/zh-CN/scenes/project-resources.json create mode 100644 src/web-ui/src/locales/zh-TW/scenes/project-resources.json create mode 100644 src/web-ui/src/shared/styles/_resource-table.scss create mode 100644 src/web-ui/src/shared/ui/ColorPicker.test.tsx diff --git a/design-system/AGENTS.md b/design-system/AGENTS.md index 1edd2fb097..8f86d3281e 100644 --- a/design-system/AGENTS.md +++ b/design-system/AGENTS.md @@ -65,3 +65,12 @@ pnpm --dir src/web-ui exec vitest run src/shared/ui/Select.test.tsx ``` These package and DOM checks do not establish native rendering or live remote behavior. + +For ColorPicker HEX drafts, flower selection, curved lightness dragging and keyboard input: + +```bash +pnpm --dir design-system run build:ui && node --test design-system/packages/ui/tests/color-picker.test.mjs design-system/packages/ui/tests/registry.test.mjs +pnpm --dir src/web-ui run test:run src/shared/ui/ColorPicker.test.tsx src/app/components/WorkspaceIcon/WorkspaceIconDialog.test.tsx +``` + +These package and DOM checks do not establish visual fidelity or live remote behavior. diff --git a/design-system/apps/design-lab/src/i18n/colorPickerMessages.ts b/design-system/apps/design-lab/src/i18n/colorPickerMessages.ts new file mode 100644 index 0000000000..b0258eb408 --- /dev/null +++ b/design-system/apps/design-lab/src/i18n/colorPickerMessages.ts @@ -0,0 +1,26 @@ +export const colorPickerEn = { + 'component.ColorPicker.description': 'An editable HEX pill and expandable flower palette with a curved lightness slider.', + 'colorPicker.color': 'Color HEX', + 'colorPicker.palette': 'Choose a color', + 'colorPicker.edit': 'Edit color', + 'colorPicker.apply': 'Apply color', + 'colorPicker.lightness': 'Lightness', +} as const; + +export const colorPickerZhCN = { + 'component.ColorPicker.description': '可编辑的 HEX 胶囊输入与可展开的花瓣色盘,搭配弧形明度滑杆。', + 'colorPicker.color': 'HEX 颜色值', + 'colorPicker.palette': '选择颜色', + 'colorPicker.edit': '编辑颜色', + 'colorPicker.apply': '应用颜色', + 'colorPicker.lightness': '明度', +} satisfies Record; + +export const colorPickerZhTW = { + 'component.ColorPicker.description': '可編輯的 HEX 膠囊輸入與可展開的花瓣色盤,搭配弧形明度滑桿。', + 'colorPicker.color': 'HEX 色彩值', + 'colorPicker.palette': '選擇顏色', + 'colorPicker.edit': '編輯顏色', + 'colorPicker.apply': '套用顏色', + 'colorPicker.lightness': '明度', +} satisfies Record; diff --git a/design-system/apps/design-lab/src/i18n/componentMetadata.ts b/design-system/apps/design-lab/src/i18n/componentMetadata.ts index 49a43ec9a9..1b3e3bab8f 100644 --- a/design-system/apps/design-lab/src/i18n/componentMetadata.ts +++ b/design-system/apps/design-lab/src/i18n/componentMetadata.ts @@ -15,6 +15,7 @@ const categoryKeys: Readonly> = { }; const descriptionKeys: Readonly> = { + ColorPicker: "component.ColorPicker.description", MobileAvatar: "component.MobileAvatar.description", MobileCheckbox: "component.MobileCheckbox.description", MobileRadioGroup: "component.MobileRadioGroup.description", diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index 66ad15905a..c625045e4b 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -7,6 +7,7 @@ import { subagentMotionEn, subagentMotionZhCN, subagentMotionZhTW } from "./suba import { thinkingIndicatorEn, thinkingIndicatorZhCN, thinkingIndicatorZhTW } from "./thinkingIndicatorMessages"; import { splitViewEn, splitViewZhCN, splitViewZhTW } from "./splitViewMessages"; import { setupWizardEn, setupWizardZhCN, setupWizardZhTW } from "./setupWizardMessages"; +import { colorPickerEn, colorPickerZhCN, colorPickerZhTW } from "./colorPickerMessages"; import type { DesignLabLocale } from "./core.mjs"; export const enUSMessages = { @@ -18,6 +19,7 @@ export const enUSMessages = { ...thinkingIndicatorEn, ...splitViewEn, ...setupWizardEn, + ...colorPickerEn, ...flowChatEn, "component.OpenBitFunSolidMark.description": "The original silver solid mark on a transparent canvas, preserving its material and shading.", "component.OpenBitFunAppIcon.description": "The application icon used by the installed product: silver mark, black rounded-square tile, and assets for different display sizes.", @@ -841,6 +843,7 @@ export const zhCNMessages = { ...thinkingIndicatorZhCN, ...splitViewZhCN, ...setupWizardZhCN, + ...colorPickerZhCN, ...flowChatZhCN, "component.OpenBitFunSolidMark.description": "透明底的银色实体标志,保留原稿的材质、光泽与立体明暗。", "component.OpenBitFunAppIcon.description": "实际应用使用的图标:银色实体标志、黑色圆角底板,以及适配不同显示尺寸的资源。", @@ -1662,6 +1665,7 @@ export const zhTWMessages = { ...thinkingIndicatorZhTW, ...splitViewZhTW, ...setupWizardZhTW, + ...colorPickerZhTW, ...flowChatZhTW, "patterns.description": "查看元件在常見介面中的組合與互動。", "patterns.title": "組合範例", diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index a8a5fd936a..862079bd90 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -1,4 +1,5 @@ import { CapsulePreview } from "../preview/CapsulePreview"; +import { ColorPickerPreview } from "../preview/ColorPickerPreview"; import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { ComponentOverview } from "../preview/ComponentOverview"; import { getComponentPresentation, getInitialPreviewState, hasComponentOverview } from "../preview/componentPresentation"; @@ -539,6 +540,7 @@ export function ComponentDetailPage({ if (component.name === "Checkbox" || component.name === "Radio") return `import { ${component.name} } from "@openbitfun/ui";\n\n<${component.name}${component.name === "Checkbox" ? ` appearance="${checkboxAppearance}"` : ""} label="${t("components.preview.notifications")}" defaultChecked />`; if (component.name === "NumberBadge") return `import { NumberBadge } from "@openbitfun/ui";\n\n;`; if (component.name === "NumberInput") return 'import { useState } from "react";\nimport { NumberInput } from "@openbitfun/ui";\n\nfunction Example() {\n const [value, setValue] = useState(8);\n return ;\n}'; + if (component.name === "ColorPicker") return 'import { ColorPicker } from "@openbitfun/ui";\n\n'; if (component.name === "Empty") return `import { Empty } from "@openbitfun/ui";\n\n`; if (flowChatPreview) { return flowChatPreview.codeSample(t); @@ -1285,6 +1287,7 @@ export function ComponentDetailPage({ ); } if (component.name === "NumberInput") return ; + if (component.name === "ColorPicker") return ; if (component.name === "NumberBadge") return ; if (component.name === "Empty") { return ( diff --git a/design-system/apps/design-lab/src/pages/ComponentsPage.tsx b/design-system/apps/design-lab/src/pages/ComponentsPage.tsx index ed21357448..b046ec013d 100644 --- a/design-system/apps/design-lab/src/pages/ComponentsPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentsPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { ComponentDetailPage } from "./ComponentDetailPage"; import { BrandPreview } from "../preview/BrandPreview"; import { CapsulePreview } from "../preview/CapsulePreview"; +import { ColorPickerPreview } from "../preview/ColorPickerPreview"; import { AppWindow, Heading, Keyboard, List, Rows3, MousePointerClick, PanelTop, ToggleLeft } from "lucide-react"; import { VoiceCallPreview, VoiceParticlePreview } from "../components/VoiceCallPreview"; import { @@ -189,6 +190,7 @@ const componentIcons = { export function ComponentCardPreview({ component }: { component: ComponentMeta }) { const { t } = useI18n(); + if (component.name === "ColorPicker") return ; if (component.category === "brand") return ; if (component.name === "VoiceCallPanel") return ; if (component.name === "VoiceParticleLogo") return ; diff --git a/design-system/apps/design-lab/src/preview/ColorPickerPreview.tsx b/design-system/apps/design-lab/src/preview/ColorPickerPreview.tsx new file mode 100644 index 0000000000..572a5e926d --- /dev/null +++ b/design-system/apps/design-lab/src/preview/ColorPickerPreview.tsx @@ -0,0 +1,10 @@ +import { useState } from 'react'; +import { ColorPicker } from '@openbitfun/ui'; +import { useI18n } from '../i18n'; + +export function ColorPickerPreview({ state = 'collapsed' }: { state?: string }) { + const { t } = useI18n(); + const [value, setValue] = useState(state === 'invalid' ? '#12' : undefined); + return ; +} diff --git a/design-system/apps/design-lab/src/preview/componentPresentation.ts b/design-system/apps/design-lab/src/preview/componentPresentation.ts index bec997f0c8..bf1c9a5924 100644 --- a/design-system/apps/design-lab/src/preview/componentPresentation.ts +++ b/design-system/apps/design-lab/src/preview/componentPresentation.ts @@ -20,6 +20,7 @@ const presentations: Record = { NavigationPanel: "composition", PageHeader: "composition", ScrollArea: "composition", SplitView: "composition", SegmentedControl: "composition", TabGroup: "composition", SetupWizard: "composition", + ColorPicker: "composition", Icon: "icons", RollingText: "motion", ShimmerText: "motion", VoiceCallPanel: "motion", ThinkingIndicator: "motion", MobileBadge: "compact", MobileButton: "compact", MobileIconButton: "compact", @@ -38,6 +39,7 @@ const presentations: Record = { }; const initialStates: Record = { + ColorPicker: "expanded", Card: "raised", Disclosure: "open", Composer: "with-context", Switch: "off", StatusPill: "success", Toolbar: "with-center", TabGroup: "selected", SegmentedControl: "selected", Listbox: "selected-option", NavigationPanel: "selected-item", ScrollArea: "auto", diff --git a/design-system/apps/design-lab/vite/component-preview-render.test.mjs b/design-system/apps/design-lab/vite/component-preview-render.test.mjs index b4837c4ab1..9b192f496f 100644 --- a/design-system/apps/design-lab/vite/component-preview-render.test.mjs +++ b/design-system/apps/design-lab/vite/component-preview-render.test.mjs @@ -118,6 +118,16 @@ test("other components formerly using the fallback render their own state specim assert.match(render("Avatar").preview, / { + const { preview, html } = render("ColorPicker"); + assert.match(preview, /data-openbitfun-component="color-picker"/); + assert.match(preview, /aria-expanded="true"/); + assert.equal((preview.match(/data-openbitfun-part="petal"/g) ?? []).length, 19); + assert.match(preview, /role="slider"[^>]*aria-label="明度"/); + assert.match(html, /import \{ ColorPicker \}/); + assert.doesNotMatch(preview, /type="color"/); +}); + test("LauncherButton exposes its interaction states with the catalog mic", () => { const { html, preview } = render("LauncherButton"); diff --git a/design-system/packages/theme-openbitfun/src/dark.tokens.json b/design-system/packages/theme-openbitfun/src/dark.tokens.json index 80d243d143..5103ac763b 100644 --- a/design-system/packages/theme-openbitfun/src/dark.tokens.json +++ b/design-system/packages/theme-openbitfun/src/dark.tokens.json @@ -124,6 +124,17 @@ "secondaryBorder": { "$type": "color", "$value": "color-mix(in srgb, {color.accent.secondary} 25%, transparent)" } }, "identity": { + "project": { + "$description": "User-selected project identity colors; independent of status, selection and assistant identity.", + "blue": { "$type": "color", "$value": "{ref.color.blue.400}" }, + "cyan": { "$type": "color", "$value": "{ref.color.cyan.400}" }, + "green": { "$type": "color", "$value": "{ref.color.green.400}" }, + "yellow": { "$type": "color", "$value": "{ref.color.amber.400}" }, + "orange": { "$type": "color", "$value": "{ref.color.amber.550}" }, + "red": { "$type": "color", "$value": "{ref.color.red.400}" }, + "purple": { "$type": "color", "$value": "{ref.color.purple.500}" }, + "pink": { "$type": "color", "$value": "{ref.color.pink.400}" } + }, "assistant": { "$description": "Named assistant identity used where it must remain distinct from the primary and secondary product accents.", "content": { "$type": "color", "$value": "{ref.color.pink.500}" }, diff --git a/design-system/packages/theme-openbitfun/src/light.tokens.json b/design-system/packages/theme-openbitfun/src/light.tokens.json index 7a60875036..f5f104d411 100644 --- a/design-system/packages/theme-openbitfun/src/light.tokens.json +++ b/design-system/packages/theme-openbitfun/src/light.tokens.json @@ -124,6 +124,17 @@ "secondaryBorder": { "$type": "color", "$value": "color-mix(in srgb, {color.accent.secondary} 25%, transparent)" } }, "identity": { + "project": { + "$description": "User-selected project identity colors; independent of status, selection and assistant identity.", + "blue": { "$type": "color", "$value": "{ref.color.blue.600}" }, + "cyan": { "$type": "color", "$value": "{ref.color.cyan.600}" }, + "green": { "$type": "color", "$value": "{ref.color.green.600}" }, + "yellow": { "$type": "color", "$value": "{ref.color.amber.525}" }, + "orange": { "$type": "color", "$value": "{ref.color.amber.625}" }, + "red": { "$type": "color", "$value": "{ref.color.red.600}" }, + "purple": { "$type": "color", "$value": "{ref.color.purple.650}" }, + "pink": { "$type": "color", "$value": "{ref.color.pink.600}" } + }, "assistant": { "$description": "Named assistant identity used where it must remain distinct from the primary and secondary product accents.", "content": { "$type": "color", "$value": "{ref.color.pink.600}" }, diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 307ced34cb..8c941f6e51 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -18,6 +18,28 @@ export function Example() { The package owns component anatomy, behavior, accessibility, and stable variants. It does not own theme selection persistence, product state, routes, locale resources, or platform APIs. +## Color selection + +`ColorPicker` combines a round swatch, an editable HEX pill, an expandable flower +palette and a curved lightness slider. Use `value` / `onValueChange` for a controlled +draft, or `defaultValue` for an independent control. Opening the palette and +focusing unchanged text do not emit a value change. `onValueChange` includes +incomplete text; owners must require a six-digit HEX value before persistence. +Enter, blur or the apply action expands three-digit shorthand. Invalid text keeps +the last valid preview. `open` / `onOpenChange` can connect an external custom-color +entry point; `defaultOpen` is available for inline authoring. + +The palette stays in normal document flow. Its petals are keyboard buttons; the +lightness slider supports pointer capture, touch, arrow keys, Page Up/Down and +Home/End. Escape first leaves text editing, then collapses the palette. Pass +localized `labels` from the owning surface. Bloom motion respects reduced motion. + +`components/ColorPicker/color.ts` owns sRGB/HSL conversion and the sampled flower +gamut. Palette colors, the current-color ring and the lightness ramp represent +editable color content; they deliberately do not follow theme or status colors. +Chrome, borders, focus and shadows use existing semantic/system tokens. No theme +color literal or new token is needed. The component has no storage or host API. + ## Pane layouts `SplitView` retains its primary and secondary content hosts across placement, @@ -607,7 +629,9 @@ matches the trigger. `variant="pill"` uses badge typography, spacing and rounded ends while retaining the selected control size. An option's `selectedLabel` shortens only the closed trigger; the expanded header, listbox and native form option retain its full `label`. Combine these with content widths for compact -inline platform or scope selectors. `Combobox` adds searchable single +inline platform or scope selectors. Set `showGroupLabels={false}` to hide group +headings while retaining the standard section spacing, and `showGroupDividers` +to separate option groups with a themed border. `Combobox` adds searchable single selection, grouped options, explicit custom-value creation and async loading states. `MultiSelect` owns multiple selection, removable tags and select-all. Controlled values are authoritative; option discovery remains host-owned. diff --git a/design-system/packages/ui/src/components/ColorPicker/ColorPicker.meta.ts b/design-system/packages/ui/src/components/ColorPicker/ColorPicker.meta.ts new file mode 100644 index 0000000000..84d04c65bf --- /dev/null +++ b/design-system/packages/ui/src/components/ColorPicker/ColorPicker.meta.ts @@ -0,0 +1,14 @@ +import type { ComponentMeta } from '../../registry.types'; + +export const colorPickerMeta = { + name: 'ColorPicker', category: 'form', maturity: 'stable', + description: 'An editable HEX pill and expandable flower palette with a curved lightness slider.', + props: [ + { name: 'value', type: 'string' }, { name: 'defaultValue', type: 'string' }, { name: 'onValueChange', type: '(value: string) => void' }, + { name: 'open', type: 'boolean' }, { name: 'defaultOpen', type: 'boolean', defaultValue: 'false' }, + { name: 'onOpenChange', type: '(open: boolean) => void' }, { name: 'disabled', type: 'boolean', defaultValue: 'false' }, + { name: 'labels', type: 'Partial' }, + ], + states: ['collapsed', 'expanded', 'invalid', 'disabled'], + tokens: ['color.surface.panel', 'color.content.primary', 'color.field.background', 'color.focus.ring', 'control.height.lg', 'radius.pill', 'shadow.sm', 'shadow.base', 'motion.duration.normal', 'type.code.md.fontFamily'], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/ColorPicker/ColorPicker.module.css b/design-system/packages/ui/src/components/ColorPicker/ColorPicker.module.css new file mode 100644 index 0000000000..a2c1a36276 --- /dev/null +++ b/design-system/packages/ui/src/components/ColorPicker/ColorPicker.module.css @@ -0,0 +1,65 @@ +@layer openbitfun.components { + .root { display: flex; flex-direction: column; align-items: center; min-inline-size: 0; max-inline-size: 100%; gap: var(--openbitfun-space-4); } + .control { display: flex; align-items: center; justify-content: center; gap: var(--openbitfun-space-3); max-inline-size: 100%; } + .swatch { + flex: 0 0 auto; + inline-size: var(--openbitfun-control-height-lg); + block-size: var(--openbitfun-control-height-lg); + padding: 0; + border: var(--openbitfun-border-width-strong) solid var(--openbitfun-color-surface-panel); + border-radius: var(--openbitfun-radius-pill); + background: var(--_color-picker-value); + box-shadow: var(--openbitfun-shadow-sm); + cursor: pointer; + transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + .swatch:hover:not(:disabled) { transform: scale(1.06); } + .swatch:active:not(:disabled) { transform: scale(.96); } + .hex { inline-size: 11rem; max-inline-size: 100%; box-shadow: var(--openbitfun-shadow-sm); } + .hex input { + font-family: var(--openbitfun-type-code-md-font-family); + font-size: var(--openbitfun-type-code-md-font-size); + font-weight: var(--openbitfun-type-code-md-font-weight); + line-height: var(--openbitfun-type-code-md-line-height); + letter-spacing: var(--openbitfun-type-code-md-letter-spacing); + text-align: center; + } + .edit { display: grid; place-items: center; padding: 0; inline-size: var(--openbitfun-control-height-sm); block-size: var(--openbitfun-control-height-sm); border: 0; border-radius: var(--openbitfun-radius-pill); background: transparent; color: var(--openbitfun-color-content-secondary); cursor: pointer; } + .edit:hover:not(:disabled) { background: var(--openbitfun-color-surface-subtle); color: var(--openbitfun-color-content-primary); } + .expansion { inline-size: min(100%, 21rem); } + .expansion[hidden] { display: none; } + .palette { position: relative; inline-size: 100%; aspect-ratio: 336 / 280; isolation: isolate; } + .wheel { position: absolute; top: 0; left: 0; inline-size: 83.333333%; aspect-ratio: 1; border-radius: var(--openbitfun-radius-pill); background: var(--_color-picker-value); box-shadow: var(--openbitfun-shadow-base); animation: bloom var(--openbitfun-motion-duration-normal) var(--openbitfun-motion-easing-standard) both; } + .wheel::before { content: ''; position: absolute; inset: 7%; border-radius: inherit; background: color-mix(in srgb, var(--_color-picker-value) 12%, var(--openbitfun-color-surface-panel)); } + .petal { + position: absolute; + z-index: 1; + inline-size: 20%; + aspect-ratio: 1; + padding: 0; + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-pill); + box-shadow: var(--openbitfun-shadow-sm); + transform: translate(-50%, -50%); + cursor: pointer; + animation: unfold var(--openbitfun-motion-duration-normal) var(--openbitfun-motion-easing-standard) backwards; + animation-delay: calc(var(--_color-picker-petal-index) * 10ms); + transition: scale var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + forced-color-adjust: none; + } + .petal:nth-child(n + 13) { z-index: 2; } + .petal:last-child { z-index: 3; } + .petal:hover:not(:disabled), .petal:focus-visible { z-index: 4; scale: 1.1; } + .petal[aria-pressed="true"] { border-color: var(--openbitfun-color-content-primary); } + .lightness { position: absolute; inset: 0; pointer-events: none; touch-action: none; outline: none; } + .track { display: block; inline-size: 100%; block-size: 100%; overflow: visible; } + .track path { pointer-events: stroke; cursor: ns-resize; } + .hitTrack { stroke: transparent; } + .thumb { position: absolute; inline-size: 9%; aspect-ratio: 1; border: var(--openbitfun-border-width-strong) solid var(--openbitfun-color-surface-panel); border-radius: var(--openbitfun-radius-pill); background: var(--_color-picker-value); box-shadow: var(--openbitfun-shadow-sm); transform: translate(-50%, -50%); pointer-events: auto; cursor: ns-resize; forced-color-adjust: none; } + .swatch:focus-visible, .edit:focus-visible, .petal:focus-visible, .lightness:focus-visible .thumb { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } + .root[data-disabled="true"] { opacity: var(--openbitfun-opacity-disabled); } + .swatch:disabled, .edit:disabled, .petal:disabled { cursor: not-allowed; } + @keyframes bloom { from { opacity: 0; transform: scale(.3) rotate(-12deg); } to { opacity: 1; transform: scale(1) rotate(0); } } + @keyframes unfold { from { opacity: 0; translate: 0 12%; scale: .6; } to { opacity: 1; translate: 0 0; scale: 1; } } + @media (prefers-reduced-motion: reduce) { .wheel, .petal { animation: none; } .swatch, .petal { transition: none; } } +} diff --git a/design-system/packages/ui/src/components/ColorPicker/ColorPicker.tsx b/design-system/packages/ui/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000000..9af705173b --- /dev/null +++ b/design-system/packages/ui/src/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,190 @@ +import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from 'react'; +import { classNames } from '../../internal/classNames'; +import { isImeOwnedKeyboardEvent } from '../../internal/ime'; +import { Icon } from '../Icon'; +import { Input } from '../Input'; +import { colorPetals, hexToHsl, hslToHex, isColorHex, lightnessFromPoint, lightnessPoint, type HslColor } from './color'; +import styles from './ColorPicker.module.css'; + +export interface ColorPickerLabels { + color: string; + palette: string; + edit: string; + apply: string; + lightness: string; +} + +export interface ColorPickerProps { + id?: string; + className?: string; + /** Controlled HEX draft. Incomplete text is emitted too; validate before persisting. */ + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + disabled?: boolean; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + labels?: Partial; +} + +const defaultLabels: ColorPickerLabels = { + color: 'Color HEX', palette: 'Choose a color', edit: 'Edit color', apply: 'Apply color', lightness: 'Lightness', +}; + +/** Inline flower palette: a swatch, editable HEX pill, and a curved lightness control. */ +export function ColorPicker({ id, className, value: controlledValue, defaultValue, onValueChange, disabled = false, open, defaultOpen = false, onOpenChange, labels: suppliedLabels }: ColorPickerProps) { + const labels = { ...defaultLabels, ...suppliedLabels }; + const paletteId = useId(); + const rampId = useId(); + const inputRef = useRef(null); + const swatchRef = useRef(null); + const dragRef = useRef<{ id: number; bounds: DOMRect }>(); + const compositionRef = useRef(false); + const skipBlurCommit = useRef(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const [editing, setEditing] = useState(false); + const [localValue, setLocalValue] = useState(() => defaultValue ?? hslToHex({ hue: 45, saturation: 90, lightness: 60 })); + const value = controlledValue ?? localValue; + const emitValue = (next: string) => { setLocalValue(next); onValueChange?.(next); }; + const expanded = (open ?? uncontrolledOpen) && !disabled; + useEffect(() => { if (!expanded) dragRef.current = undefined; }, [expanded]); + const valid = isColorHex(value); + const model = useRef({ hex: '', hsl: { hue: 45, saturation: 90, lightness: 60 } }); + if (valid && value.toUpperCase() !== model.current.hex) { + model.current = { hex: value.toUpperCase(), hsl: hexToHsl(value) }; + } + const { hsl } = model.current; + const hex = model.current.hex || hslToHex(hsl); + const changeOpen = (next: boolean) => { + if (disabled) return; + setUncontrolledOpen(next); + onOpenChange?.(next); + }; + const choose = (next: HslColor) => { + if (disabled) return; + const nextHex = hslToHex(next); + // Retain hue/saturation at the black and white ends of a continuous drag. + model.current = { hex: nextHex, hsl: next }; + emitValue(nextHex); + }; + const commit = () => { + let next = value.trim().replace(/^#/, ''); + if (/^[\da-f]{3}$/i.test(next)) next = [...next].map(character => character + character).join(''); + if (/^[\da-f]{6}$/i.test(next)) { + const nextHex = `#${next.toUpperCase()}`; + if (nextHex !== value) emitValue(nextHex); + setEditing(false); + return true; + } + return false; + }; + const finishEdit = () => { + if (!commit()) return; + if (inputRef.current?.ownerDocument.activeElement === inputRef.current) { + skipBlurCommit.current = true; + inputRef.current?.blur(); + } + }; + const setLightness = (lightness: number) => choose({ ...hsl, lightness: Math.max(0, Math.min(100, lightness)) }); + const move = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.id !== event.pointerId || disabled) return; + const { bounds } = drag; + if (bounds.width <= 0 || bounds.height <= 0) return; + setLightness(lightnessFromPoint((event.clientX - bounds.left) / bounds.width * 336, (event.clientY - bounds.top) / bounds.height * 280)); + }; + const sliderKey = (event: KeyboardEvent) => { + if (disabled) return; + const delta = event.shiftKey ? 10 : 1; + const keys: Record = { + ArrowUp: hsl.lightness + delta, ArrowRight: hsl.lightness + delta, + ArrowDown: hsl.lightness - delta, ArrowLeft: hsl.lightness - delta, + PageUp: hsl.lightness + 10, PageDown: hsl.lightness - 10, Home: 0, End: 100, + }; + const next = keys[event.key]; + if (typeof next === 'number') { event.preventDefault(); setLightness(next); } + }; + const thumb = lightnessPoint(hsl.lightness); + const arcTop = lightnessPoint(100), arcBottom = lightnessPoint(0); + const arc = `M ${arcTop.x} ${arcTop.y} A 166 166 0 0 1 ${arcBottom.x} ${arcBottom.y}`; + + return
{ + if (event.key !== 'Escape' || isImeOwnedKeyboardEvent(event, compositionRef.current)) return; + if (editing) { + event.preventDefault(); event.stopPropagation(); skipBlurCommit.current = true; + if (value !== hex) emitValue(hex); + setEditing(false); inputRef.current?.blur(); + } + else if (expanded) { event.stopPropagation(); changeOpen(false); swatchRef.current?.focus(); } + }}> +
+ } /> +
+ +
; +} diff --git a/design-system/packages/ui/src/components/ColorPicker/color.ts b/design-system/packages/ui/src/components/ColorPicker/color.ts new file mode 100644 index 0000000000..3db90817cf --- /dev/null +++ b/design-system/packages/ui/src/components/ColorPicker/color.ts @@ -0,0 +1,54 @@ +/** sRGB content math for the picker; these colors are editable data, not UI chrome. */ +export interface HslColor { hue: number; saturation: number; lightness: number } + +export function isColorHex(value: string): boolean { + return /^#[\da-f]{6}$/i.test(value); +} + +export function hexToHsl(hex: string): HslColor { + const channel = (start: number) => Number.parseInt(hex.slice(start, start + 2), 16) / 255; + const r = channel(1), g = channel(3), b = channel(5); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + const lightness = (max + min) / 2; + let hue = 0; + if (delta) { + if (max === r) hue = ((g - b) / delta) % 6; + else if (max === g) hue = (b - r) / delta + 2; + else hue = (r - g) / delta + 4; + } + return { + hue: (hue * 60 + 360) % 360, + saturation: delta ? delta / (1 - Math.abs(2 * lightness - 1)) * 100 : 0, + lightness: lightness * 100, + }; +} + +export function hslToHex({ hue, saturation, lightness }: HslColor): string { + const l = Math.max(0, Math.min(100, lightness)) / 100; + const s = Math.max(0, Math.min(100, saturation)) / 100; + const a = s * Math.min(l, 1 - l); + const channel = (n: number) => { + const k = (n + hue / 30) % 12; + const value = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + return Math.round(255 * value).toString(16).padStart(2, '0'); + }; + return `#${channel(0)}${channel(8)}${channel(4)}`.toUpperCase(); +} + +export const colorPetals: readonly HslColor[] = [ + ...Array.from({ length: 12 }, (_, index) => ({ hue: (45 - index * 30 + 360) % 360, saturation: 68, lightness: 56 })), + ...Array.from({ length: 6 }, (_, index) => ({ hue: (45 - index * 60 + 360) % 360, saturation: 48, lightness: 82 })), + { hue: 0, saturation: 0, lightness: 100 }, +]; + +export function lightnessFromPoint(x: number, y: number): number { + const angle = Math.atan2(y - 140, x - 140) * 180 / Math.PI; + return Math.round(Math.max(0, Math.min(100, (30 - angle) / 60 * 100))); +} + +export function lightnessPoint(lightness: number): { x: number; y: number } { + const angle = (30 - lightness * 0.6) * Math.PI / 180; + return { x: 140 + 166 * Math.cos(angle), y: 140 + 166 * Math.sin(angle) }; +} diff --git a/design-system/packages/ui/src/components/ColorPicker/index.ts b/design-system/packages/ui/src/components/ColorPicker/index.ts new file mode 100644 index 0000000000..477b37d011 --- /dev/null +++ b/design-system/packages/ui/src/components/ColorPicker/index.ts @@ -0,0 +1 @@ +export { ColorPicker, type ColorPickerProps, type ColorPickerLabels } from './ColorPicker'; diff --git a/design-system/packages/ui/src/components/Select/Select.meta.ts b/design-system/packages/ui/src/components/Select/Select.meta.ts index 29069e8e88..8f17a3b001 100644 --- a/design-system/packages/ui/src/components/Select/Select.meta.ts +++ b/design-system/packages/ui/src/components/Select/Select.meta.ts @@ -7,6 +7,8 @@ export const selectMeta = { name: "Select", props: [ { name: "options", type: "readonly SelectOption[] (supports selectedLabel, tooltip, testId and data-* attributes)" }, + { defaultValue: "true", name: "showGroupLabels", type: "boolean" }, + { defaultValue: "false", name: "showGroupDividers", type: "boolean" }, { name: "value", type: "SelectValue" }, { name: "defaultValue", type: "SelectValue" }, { name: "placeholder", type: "string" }, diff --git a/design-system/packages/ui/src/components/Select/Select.module.css b/design-system/packages/ui/src/components/Select/Select.module.css index e37c75fee0..9ce4b0c5bf 100644 --- a/design-system/packages/ui/src/components/Select/Select.module.css +++ b/design-system/packages/ui/src/components/Select/Select.module.css @@ -242,6 +242,11 @@ background: var(--openbitfun-color-border-subtle); } + .dividedGroup { + padding-block-start: var(--openbitfun-overlay-menu-section-gap); + border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + } + .options { display: flex; overflow: hidden; diff --git a/design-system/packages/ui/src/components/Select/Select.tsx b/design-system/packages/ui/src/components/Select/Select.tsx index 17c3674a54..23c0144f61 100644 --- a/design-system/packages/ui/src/components/Select/Select.tsx +++ b/design-system/packages/ui/src/components/Select/Select.tsx @@ -68,6 +68,10 @@ export interface SelectProps placeholder?: string; /** Size the expanded surface to its trigger or the full option labels. */ popupWidth?: "trigger" | "content"; + /** Draw a divider before each subsequent option group. */ + showGroupDividers?: boolean; + /** Hide group headings while retaining the standard section spacing. */ + showGroupLabels?: boolean; size?: SelectSize; value?: SelectValue; /** A compact visual treatment using the system's badge typography and radius. */ @@ -154,6 +158,8 @@ export const Select = forwardRef(function Select placeholder, popupWidth = "trigger", required = false, + showGroupDividers = false, + showGroupLabels = true, size = "md", style, tabIndex, @@ -435,8 +441,12 @@ export const Select = forwardRef(function Select ) : ( <> {ungrouped.map(renderListboxOption)} - {[...groups].map(([label, groupedOptions]) => ( - + {[...groups].map(([label, groupedOptions], index) => ( + 0 || ungrouped.length > 0) ? styles.dividedGroup : undefined} + key={label} + label={showGroupLabels ? label : undefined} + > {groupedOptions.map(renderListboxOption)} ))} @@ -486,7 +496,7 @@ export const Select = forwardRef(function Select )} {ungrouped.map(renderNativeOption)} {[...groups].map(([label, groupedOptions]) => ( - {groupedOptions.map(renderNativeOption)} + {groupedOptions.map(renderNativeOption)} ))} - + @@ -1392,11 +1391,11 @@ const WorkspaceItem: React.FC = ({ data-workspace-id={workspace.id} > } - onClick={handleCreateProjectSession} - data-testid="nav-workspace-menu-create-session" + leading={} + onClick={handleOpenFiles} + data-testid="nav-workspace-menu-open-directory" > - {t('nav.sessions.newSession')} + {t('nav.workspaces.actions.openDirectory')} = ({ > {t('nav.shell.actions.newTerminal')} - } - onClick={() => { - setMenuOpen(false); - setRelatedPathsDialogOpen(true); - }} - data-testid="nav-workspace-menu-related-paths" - > - {t('nav.workspaces.actions.manageRelatedPaths')} - - } - onClick={handleOpenProjectPermissions} - data-testid="nav-workspace-menu-project-permissions" - > - {t('nav.workspaces.actions.manageProjectPermissions')} - } onClick={handleOpenScheduledJobs}> {t('nav.scheduledJobs.open')} @@ -1526,24 +1508,6 @@ const WorkspaceItem: React.FC = ({ validator={validateWorkspaceName} required={false} /> - - - setRelatedPathsDialogOpen(false)} - /> - - - - - setProjectPermissionsDialogOpen(false)} - /> - - {portForwardConnectionId ? ( diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index 531f83f1ef..7807238908 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -445,11 +445,16 @@ &__workspace-item-name-line { display: flex; align-items: center; + gap: var(--openbitfun-space-2); min-width: 0; flex: 1 1 0; overflow: hidden; } + &__workspace-item-name-line > &__workspace-item-label { + flex: 0 1 auto; + } + &__workspace-item[data-openbitfun-state~='remote'] &__workspace-item-label { text-overflow: clip; } @@ -826,18 +831,7 @@ } &__workspace-item-badge { - display: inline-flex; - align-items: center; - justify-content: center; flex-shrink: 0; - padding: 1px 6px; - border-radius: 999px; - background: color-mix(in srgb, var(--openbitfun-color-action-secondary-hover) 88%, transparent); - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-micro-font-size); - font-weight: var(--openbitfun-type-heading-page-font-weight); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); - white-space: nowrap; } &__workspace-item-branch { @@ -1391,8 +1385,7 @@ color: var(--openbitfun-color-content-secondary); } - &__workspace-item-remote, - &__workspace-item-badge { + &__workspace-item-remote { @include nav-font.nav-panel-text-meta; font-size: var(--openbitfun-type-meta-font-size); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts index b40ab3bcbe..a22407956d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts @@ -263,10 +263,11 @@ describe('WorkspaceListSection layout styles', () => { const itemSource = readWorkspaceItemSource(); const listSource = readWorkspaceListSource(); const stylesheet = readWorkspaceListStylesheet(); + const iconSource = readFileSync(fileURLToPath(new URL('../../../WorkspaceIcon/WorkspaceIcon.tsx', import.meta.url)), 'utf8'); expect(itemSource).toContain(''); - expect(itemSource).toContain(''); - expect(itemSource).toContain(''); + expect(itemSource).toContain(''); + expect(iconSource).toContain(' { expect(assistant).not.toContain('nav-workspace-session-region'); expect(assistant).not.toContain('nav-workspace-sessions-toggle'); - expect(assistant).not.toContain('nav-workspace-files-btn'); + expect(assistant).not.toContain('nav-workspace-project-resources-btn'); expect(assistant).not.toContain('handleCollapseToggle'); for (const removedAction of ['project-permissions', 'reveal', 'open-session-storage', 'manage-sessions', 'copy-path']) { expect(assistant).not.toContain(`data-testid="nav-workspace-menu-${removedAction}"`); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance.ts index 4ed7f5c211..f87f1639bd 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.appearance.ts @@ -3,8 +3,6 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; export const workspaceProjectPermissionsDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'workspace-project-permissions-dialog', parts: [ - { id: 'root' }, { id: 'intro' }, { id: 'section' }, { id: 'sectionHeader' }, - { id: 'grants' }, { id: 'grant' }, { id: 'rules' }, { id: 'rule' }, - { id: 'ruleActions' }, { id: 'empty' }, { id: 'footer' }, + { id: 'root' }, { id: 'grant' }, { id: 'rule' }, { id: 'footer' }, { id: 'addForm' }, ], }; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.scss index 8e79b706e1..40c6041228 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.scss @@ -1,165 +1,44 @@ +@use '../../../../../shared/styles/resource-table'; + .workspace-project-permissions-dialog { - display: flex; - flex-direction: column; - gap: 16px; - min-width: 0; - padding-block: 4px 16px; + min-inline-size: 0; color: var(--openbitfun-color-content-primary); - &__modal { - overflow-x: hidden; + &__heading { + display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; + gap: var(--openbitfun-space-3); margin-block-end: var(--openbitfun-space-5); + h2 { margin: 0; font-size: var(--openbitfun-type-heading-section-font-size); font-weight: var(--openbitfun-type-heading-section-font-weight); } } - - &__intro { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 8px 10px; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: 6px; - background: var(--openbitfun-color-surface-subtle); + &__section-action { + padding-inline: var(--openbitfun-space-3); border-color: transparent; border-radius: var(--openbitfun-radius-base); color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-body-sm-line-height); - - > :where(svg, [data-openbitfun-component='icon']) { - flex-shrink: 0; - margin-top: 1px; - color: var(--openbitfun-color-accent-default); - } - - p { - margin: 0; - } - } - - &__section { - display: flex; - flex-direction: column; - min-width: 0; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: 8px; - overflow: hidden; - } - - &__section-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 44px; - padding: 8px 12px; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - background: var(--openbitfun-color-surface-subtle); - } - - &__grants, - &__rules { - display: flex; - flex-direction: column; - min-width: 0; - } - - &__grant-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - min-width: 0; - padding: 10px 12px; - - + .workspace-project-permissions-dialog__grant-row { - border-top: 1px solid var(--openbitfun-color-border-subtle); - } - } - - &__grant-copy { - display: grid; - grid-template-columns: minmax(90px, 0.7fr) minmax(140px, 1.5fr) auto; - align-items: center; - gap: 12px; - min-width: 0; - - code { - min-width: 0; - overflow: hidden; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-sm-font-size); - white-space: nowrap; - } - - span { - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); - white-space: nowrap; - } - } - - &__rule-heading, - &__rule-row { - display: grid; - grid-template-columns: minmax(120px, 0.55fr) minmax(150px, 0.65fr) minmax(220px, 1fr) auto; - align-items: center; - gap: 10px; - min-width: 0; - padding-inline: 12px; - } - - &__rule-heading { - min-height: 32px; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); - background: var(--openbitfun-color-surface-subtle); - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - } - - &__rule-row { - padding-block: 10px; - - + .workspace-project-permissions-dialog__rule-row { - border-top: 1px solid var(--openbitfun-color-border-subtle); - } - } - - &__rule-actions { - display: flex; - align-items: center; - gap: 2px; - justify-content: flex-end; - } - - &__footer { - flex-wrap: wrap; - } - - &__empty { - padding: 18px 12px; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-body-sm-line-height); - text-align: center; - } -} - -@media (max-width: 760px) { - .workspace-project-permissions-dialog { - &__grant-copy, - &__rule-row { - grid-template-columns: minmax(0, 1fr); - } - - &__grant-copy span { - white-space: normal; - } - - &__rule-heading { - display: none; - } - - &__rule-actions { - justify-content: flex-end; - } - } + &:is(:hover, :active):not(:disabled) { + border-color: var(--openbitfun-color-border-default); color: var(--openbitfun-color-content-primary); text-decoration: none; + } + } + &__empty { margin: 0; padding: var(--openbitfun-space-3); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); } + &__error { + display: flex; align-items: center; flex-wrap: wrap; gap: var(--openbitfun-space-2); + padding: var(--openbitfun-space-4); color: var(--openbitfun-color-status-danger-content); font-size: var(--openbitfun-type-body-sm-font-size); + p { width: 100%; margin: 0; overflow-wrap: anywhere; } + } + &__modal { block-size: min(40rem, calc(100dvh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter))); } + &__toolbar { + display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; flex-wrap: wrap; + gap: var(--openbitfun-space-3); padding: 0 var(--openbitfun-overlay-dialog-content-padding-lg) var(--openbitfun-space-4); + } + &__body { flex: 1; min-block-size: 0; } + &__table-wrap { overflow-x: auto; flex-shrink: 0; } + &__table { + @include resource-table.table(minmax(0, 0.85fr) minmax(0, 1.5fr) minmax(0, 0.75fr) minmax(0, 1fr) minmax(9.5rem, max-content)); + min-inline-size: 42rem; + } + &__row-actions { display: flex; align-items: center; justify-content: center; gap: var(--openbitfun-space-1); } + &__add-form { display: flex; flex-direction: column; gap: var(--openbitfun-space-4); } + &__editor { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr); gap: var(--openbitfun-space-4); + padding: var(--openbitfun-space-4); background: var(--openbitfun-color-surface-tertiary); + } + &__pending { margin-inline-end: auto; color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); } + &__footer { min-block-size: var(--openbitfun-control-height-sm); flex-wrap: wrap; } } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.tsx index a7cf4a5325..967382187d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsDialog.tsx @@ -1,21 +1,23 @@ -import { OverflowText, +import { Alert, OverflowText, Button, Icon, IconButton, Input, Select, type SelectOption, - Tooltip, + Field, + StatusPill, Dialog, DialogBody, + DialogDescription, DialogClose, DialogFooter, DialogHeader, DialogHeading, DialogTitle, } from '@openbitfun/ui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Save, ShieldCheck } from 'lucide-react'; +import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { Save } from 'lucide-react'; import { confirmDanger } from '@/infrastructure/confirm-dialog'; import { @@ -25,9 +27,12 @@ import { type ProjectPermissionRule, } from '@/infrastructure/api/service-api/PermissionAPI'; import { useI18n } from '@/infrastructure/i18n'; +import { getActiveSurfaceScope, onSurfaceActivated, type SurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { notificationService } from '@/shared/notification-system'; import type { WorkspaceInfo } from '@/shared/types'; import { createLogger } from '@/shared/utils/logger'; +import { ProjectResourceSummary } from '@/app/scenes/project-resources/ProjectResourceSummary'; +import { ProjectResourceList } from '@/app/scenes/project-resources/ProjectResourceList'; import './WorkspaceProjectPermissionsDialog.scss'; const log = createLogger('WorkspaceProjectPermissionsDialog'); @@ -58,7 +63,9 @@ interface DraftRule extends ProjectPermissionRule { interface WorkspaceProjectPermissionsDialogProps { workspace: WorkspaceInfo; isOpen: boolean; - onClose: () => void; + onClose?: () => void; + presentation?: 'dialog' | 'inline'; + refreshKey?: number; } function toDraftRule(rule: ProjectPermissionRule): DraftRule { @@ -77,20 +84,59 @@ function rulesEqual(left: ProjectPermissionRule[], right: ProjectPermissionRule[ }); } -export const WorkspaceProjectPermissionsDialog: React.FC = ({ +export function WorkspaceProjectPermissionsPanel({ workspace, isActive, refreshKey }: { + workspace: WorkspaceInfo; + isActive: boolean; + refreshKey?: number; +}) { + return ; +} + +export const WorkspaceProjectPermissionsDialog: React.FC = (props) => { + const scope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); + return ; +}; + +function WorkspaceProjectPermissionsContent({ workspace, isOpen, onClose, -}) => { - const { t, formatDate } = useI18n('settings/runtime'); + presentation = 'dialog', + refreshKey, + scope, +}: WorkspaceProjectPermissionsDialogProps & { scope: SurfaceScope }) { + const { t, formatDate, formatNumber } = useI18n('settings/runtime'); + const [listOpen, setListOpen] = useState(false); + const [editingRuleId, setEditingRuleId] = useState(null); + const [addition, setAddition] = useState<{ rule: ProjectPermissionRule; stage: boolean } | null>(null); + const [additionError, setAdditionError] = useState(null); + const additionEpoch = useRef(0); + const additionFormId = useId(); + const dialogVisible = isOpen && (presentation === 'dialog' || listOpen); + const dialogEpoch = useRef(0); const [permissionGrants, setPermissionGrants] = useState([]); - const [grantsLoading, setGrantsLoading] = useState(false); - const [rulesLoading, setRulesLoading] = useState(false); + const [grantsLoading, setGrantsLoading] = useState(true); + const [rulesLoading, setRulesLoading] = useState(true); + const [grantsError, setGrantsError] = useState(null); + const [rulesError, setRulesError] = useState(null); const [rulesSaving, setRulesSaving] = useState(false); const [mutationKey, setMutationKey] = useState(null); const [savedRules, setSavedRules] = useState([]); const [draftRules, setDraftRules] = useState([]); const [rulesRevision, setRulesRevision] = useState(null); + const mounted = useRef(true); + const active = useRef(dialogVisible); + const grantsRequest = useRef(0); + const rulesRequest = useRef(0); + const mutationRunning = useRef(false); + const dirty = useRef(false); + useLayoutEffect(() => { active.current = dialogVisible; dialogEpoch.current += 1; }, [dialogVisible]); + useEffect(() => { + if (!isOpen) { setListOpen(false); setAddition(null); setAdditionError(null); additionEpoch.current += 1; } + }, [isOpen]); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); + const current = useCallback(() => mounted.current && scope.isCurrent(), [scope]); const effectOptions = useMemo( () => EFFECTS.map((effect) => ({ value: effect, @@ -100,57 +146,73 @@ export const WorkspaceProjectPermissionsDialog: React.FC { + if (!current()) return; + const request = ++grantsRequest.current; setGrantsLoading(true); + setGrantsError(null); try { - setPermissionGrants(await permissionAPI.listProjectGrants(workspace.id)); + const grants = await permissionAPI.listProjectGrants(workspace.id); + if (current() && request === grantsRequest.current) setPermissionGrants(grants); } catch (error) { + if (!current() || request !== grantsRequest.current) return; log.error('Failed to load project permission grants', { workspaceId: workspace.id, error }); - notificationService.error(t('projectPermissions.grantsLoadFailed')); + setGrantsError(error instanceof Error ? error.message : String(error)); } finally { - setGrantsLoading(false); + if (current() && request === grantsRequest.current) setGrantsLoading(false); } - }, [t, workspace.id]); + }, [current, workspace.id]); const loadRules = useCallback(async () => { + if (!current() || dirty.current || mutationRunning.current) return; + const request = ++rulesRequest.current; setRulesLoading(true); + setRulesError(null); try { const response = await permissionAPI.getProjectRules(workspace.id); + if (!current() || request !== rulesRequest.current) return; setSavedRules(response.rules); setDraftRules(response.rules.map(toDraftRule)); setRulesRevision(response.revision); } catch (error) { + if (!current() || request !== rulesRequest.current) return; log.error('Failed to load project permission rules', { workspaceId: workspace.id, error }); setRulesRevision(null); - notificationService.error(t('projectPermissions.rulesLoadFailed')); + setRulesError(error instanceof Error ? error.message : String(error)); } finally { - setRulesLoading(false); + if (current() && request === rulesRequest.current) setRulesLoading(false); } - }, [t, workspace.id]); + }, [current, workspace.id]); useEffect(() => { if (!isOpen) { return; } void Promise.all([loadGrants(), loadRules()]); - }, [isOpen, loadGrants, loadRules]); + }, [isOpen, loadGrants, loadRules, refreshKey]); const handleRemovePermissionGrant = async (grant: PermissionGrant) => { + if (!current() || !active.current || mutationRunning.current) return; + const confirmationEpoch = dialogEpoch.current; const confirmed = await confirmDanger( t('projectPermissions.removeGrantTitle'), t('projectPermissions.removeGrantMessage', { action: grant.action, resource: grant.resource }), { confirmText: t('projectPermissions.removeGrantConfirm') }, ); - if (!confirmed) { + if (!confirmed || !current() || !active.current || confirmationEpoch !== dialogEpoch.current || mutationRunning.current) { return; } const key = `${grant.action}\n${grant.resource}`; + mutationRunning.current = true; setMutationKey(key); try { await permissionAPI.removeProjectGrant(workspace.id, grant); + if (!current()) return; await loadGrants(); + if (!current()) return; notificationService.success(t('projectPermissions.removeGrantSuccess')); } catch (error) { + if (!current()) return; log.error('Failed to remove project permission grant', { workspaceId: workspace.id, action: grant.action, @@ -159,34 +221,41 @@ export const WorkspaceProjectPermissionsDialog: React.FC { - if (permissionGrants.length === 0) { + if (!current() || !active.current || mutationRunning.current || permissionGrants.length === 0) { return; } + const confirmationEpoch = dialogEpoch.current; const confirmed = await confirmDanger( t('projectPermissions.clearGrantsTitle'), t('projectPermissions.clearGrantsMessage'), { confirmText: t('projectPermissions.clearGrantsConfirm') }, ); - if (!confirmed) { + if (!confirmed || !current() || !active.current || confirmationEpoch !== dialogEpoch.current || mutationRunning.current) { return; } + mutationRunning.current = true; setMutationKey('*'); try { await permissionAPI.clearProjectGrants(workspace.id); + if (!current()) return; await loadGrants(); + if (!current()) return; notificationService.success(t('projectPermissions.clearGrantsSuccess')); } catch (error) { + if (!current()) return; log.error('Failed to clear project permission grants', { workspaceId: workspace.id, error }); notificationService.error(t('projectPermissions.clearGrantsFailed')); } finally { - setMutationKey(null); + mutationRunning.current = false; + if (current()) setMutationKey(null); } }; @@ -209,22 +278,27 @@ export const WorkspaceProjectPermissionsDialog: React.FC toProjectRules(draftRules), [draftRules]); const rulesDirty = !rulesEqual(projectRules, savedRules); + useLayoutEffect(() => { dirty.current = rulesDirty || addition !== null; }, [rulesDirty, addition]); const rulesValid = projectRules.every((rule) => rule.action.trim() && rule.resource.trim()); - const isBusy = grantsLoading || rulesLoading || rulesSaving || isMutationRunning; + const isBusy = !isOpen || grantsLoading || rulesLoading || rulesSaving || isMutationRunning; const handleSaveRules = async () => { - if (!rulesValid || rulesRevision === null) { + if (!current() || !active.current || mutationRunning.current || isBusy || !rulesDirty || !rulesValid || rulesRevision === null) { return; } + mutationRunning.current = true; setRulesSaving(true); try { const response = await permissionAPI.saveProjectRules(workspace.id, projectRules, rulesRevision); + if (!current()) return; setSavedRules(response.rules); setDraftRules(response.rules.map(toDraftRule)); setRulesRevision(response.revision); + setEditingRuleId(null); notificationService.success(t('projectPermissions.rulesSaveSuccess')); } catch (error) { + if (!current()) return; log.error('Failed to save project permission rules', { workspaceId: workspace.id, error }); notificationService.error( error instanceof Error && error.message.includes('changed outside OpenBitFun') @@ -232,204 +306,221 @@ export const WorkspaceProjectPermissionsDialog: React.FC { setDraftRules(savedRules.map(toDraftRule)); + setEditingRuleId(null); }; - return ( - { - if (!nextOpen && !isBusy) onClose(); - }} - size="xl" - > + const addAuthorization = () => { + if (!current() || !isOpen || isBusy || rulesRevision === null) return; + additionEpoch.current += 1; + setAddition({ rule: { action: '', resource: '', effect: 'allow' }, stage: dialogVisible || rulesDirty }); + setAdditionError(null); + }; + const closeAddition = () => { + if (rulesSaving || isMutationRunning) return; + additionEpoch.current += 1; + setAddition(null); + setAdditionError(null); + }; + const submitAddition = async (event: React.FormEvent) => { + event.preventDefault(); + if (!current() || !isOpen || !addition || isBusy || mutationRunning.current || rulesRevision === null) return; + const rule = { ...addition.rule, action: addition.rule.action.trim(), resource: addition.rule.resource.trim() }; + if (!rule.action || !rule.resource) return; + if (addition.stage) { + setDraftRules(rules => [...rules, toDraftRule(rule)]); + closeAddition(); + setListOpen(true); + return; + } + const epoch = additionEpoch.current; + mutationRunning.current = true; + setRulesSaving(true); + setAdditionError(null); + try { + const response = await permissionAPI.saveProjectRules(workspace.id, [...savedRules, rule], rulesRevision); + if (!current()) return; + setSavedRules(response.rules); + setDraftRules(response.rules.map(toDraftRule)); + setRulesRevision(response.revision); + if (epoch === additionEpoch.current) setAddition(null); + notificationService.success(t('projectPermissions.rulesSaveSuccess')); + } catch (error) { + if (!current() || epoch !== additionEpoch.current) return; + log.error('Failed to add project permission rule', { workspaceId: workspace.id, error }); + setAdditionError(t(error instanceof Error && error.message.includes('changed outside OpenBitFun') + ? 'projectPermissions.rulesConflict' : 'projectPermissions.rulesSaveFailed')); + } finally { + mutationRunning.current = false; + if (current()) setRulesSaving(false); + } + }; + const closeList = () => { + if (rulesSaving || isMutationRunning) return; + if (presentation === 'dialog') onClose?.(); + else setListOpen(false); + }; + const entries = [ + ...savedRules.map((rule, index) => ({ ...rule, key: `rule:${index}`, source: t('projectPermissions.rulesTitle') })), + ...permissionGrants.map(grant => ({ ...grant, key: `grant:${grant.action}:${grant.resource}`, effect: 'allow' as const, source: t('projectPermissions.grantsTitle') })), + ]; + const complete = !grantsLoading && !rulesLoading && !grantsError && !rulesError; + const permissionsEmpty = complete && entries.length === 0; + const effectPill = (effect: ProjectPermissionEffect) => + {t(`projectPermissions.effects.${effect}`)} + ; + const feedback = <> + {(grantsLoading || rulesLoading) &&

{t('loading.text')}

} + {grantsError &&
+ {t('projectPermissions.grantsLoadFailed')} +

{grantsError}

+
} + {rulesError &&
+ {t('projectPermissions.rulesLoadFailed')} +

{rulesError}

+
} + ; + const ruleFields = (rule: ProjectPermissionRule, onChange: (update: Partial) => void) => <> + + onChange({ resource: event.target.value })} size="sm" autoFocus /> + + + updateDraftRule(rule.localId, { effect: value as ProjectPermissionEffect })} - /> - updateDraftRule(rule.localId, { resource: event.target.value })} - size="sm" - /> -
- - moveDraftRule(index, -1)} - icon={} - /> - - - moveDraftRule(index, 1)} - icon={} - /> - - - setDraftRules((rules) => rules.filter(({ localId }) => localId !== rule.localId))} - icon={} - /> - -
- - ))} - - )} + {editingRuleId === rule.localId &&
{editor(rule)}
} + )} + {permissionGrants.map(grant =>
+
{grant.action}
{grant.resource}
+
{effectPill('allow')}
{t('projectPermissions.grantsTitle')}
+
+ void handleRemovePermissionGrant(grant)} icon={} /> +
+
)} + {complete && draftRules.length + permissionGrants.length === 0 &&
{t('projectPermissions.empty')}
} - - - + + - {rulesDirty ? ( - - - - - ) : null} + + {rulesDirty ? <> + {t('projectPermissions.unsaved')} + + + : } + + {dialogVisible && additionDialog}
- ); -}; + {!dialogVisible && additionDialog} + ; +} export default WorkspaceProjectPermissionsDialog; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsPanel.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsPanel.test.tsx new file mode 100644 index 0000000000..0beed368fa --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceProjectPermissionsPanel.test.tsx @@ -0,0 +1,252 @@ +/** @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { WorkspaceKind, WorkspaceType, type WorkspaceInfo } from '@/shared/types'; +import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; +import WorkspaceProjectPermissionsDialog, { WorkspaceProjectPermissionsPanel } from './WorkspaceProjectPermissionsDialog'; + +const mocks = vi.hoisted(() => ({ + grants: vi.fn(), rules: vi.fn(), save: vi.fn(), remove: vi.fn(), clear: vi.fn(), confirm: vi.fn(), + success: vi.fn(), error: vi.fn(), +})); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key, formatDate: String, formatNumber: String }) })); +vi.mock('@/infrastructure/api/service-api/PermissionAPI', () => ({ permissionAPI: { + listProjectGrants: mocks.grants, getProjectRules: mocks.rules, saveProjectRules: mocks.save, + removeProjectGrant: mocks.remove, clearProjectGrants: mocks.clear, +} })); +vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmDanger: mocks.confirm })); +vi.mock('@/shared/notification-system', () => ({ notificationService: { success: mocks.success, error: mocks.error } })); + +const workspace: WorkspaceInfo = { id: 'owned', name: 'Owned project', rootPath: '/owned', + workspaceKind: WorkspaceKind.Normal, workspaceType: WorkspaceType.SingleProject, + languages: [], tags: [], openedAt: '', lastAccessed: '' }; +const rule = { action: 'read', resource: 'src/*', effect: 'ask' }; +const grant = { projectId: 'owned', action: 'read', resource: 'docs/*', createdAtMs: 1 }; +let container: HTMLDivElement; +let root: Root; +const render = async (isActive = true, refreshKey = 0, target = workspace) => { + await act(async () => root.render()); +}; +const resource = () => document.querySelector('input[aria-label="projectPermissions.resource"]')!; +const editResource = (value: string) => act(() => { + const input = resource(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +}); +const button = (label: string, parent: ParentNode = document.body) => [...parent.querySelectorAll('button')] + .find(item => item.textContent === label || item.getAttribute('aria-label') === label)!; +const click = async (label: string) => act(async () => { button(label).click(); }); +const openList = async () => act(async () => container.querySelector('.project-resource-summary__pane--action')!.click()); +const editRule = async () => click('projectPermissions.editAuthorization'); + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + resetDeviceSurfaceForTest(); + vi.resetAllMocks(); + mocks.grants.mockResolvedValue([grant]); + mocks.rules.mockResolvedValue({ rules: [rule], revision: 'v1' }); + mocks.save.mockImplementation(async (_workspaceId, rules) => ({ rules, revision: 'v2' })); + mocks.confirm.mockResolvedValue(true); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(() => { act(() => root.unmount()); container.remove(); resetDeviceSurfaceForTest(); }); + +it('combines both sources in the summary and saves edited rules with the owning project and revision', async () => { + await render(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(mocks.rules).toHaveBeenCalledExactlyOnceWith('owned'); + expect(container.querySelector('.project-resource-summary__value')?.textContent).toBe('2'); + expect(container.querySelector('[title^="projectPermissions.grantsTitle"]')).not.toBeNull(); + expect(container.querySelector('[title^="projectPermissions.rulesTitle"]')).not.toBeNull(); + await openList(); + expect(document.querySelectorAll('[role="table"] > .workspace-project-permissions-dialog__row')).toHaveLength(2); + await editRule(); + editResource('tests/*'); + expect(mocks.save).not.toHaveBeenCalled(); + await click('projectPermissions.saveRules'); + expect(mocks.save).toHaveBeenCalledExactlyOnceWith('owned', [{ ...rule, resource: 'tests/*' }], 'v1'); + expect(button('projectPermissions.saveRules')).toBeUndefined(); + await editRule(); + editResource('lib/*'); + await click('projectPermissions.saveRules'); + expect(mocks.save).toHaveBeenLastCalledWith('owned', [{ ...rule, resource: 'lib/*' }], 'v2'); +}); + +it('preserves unsaved rules through refresh and tab reactivation, then supports discard and reload', async () => { + await render(); + await openList(); + await editRule(); + editResource('draft/*'); + mocks.rules.mockResolvedValue({ rules: [{ ...rule, resource: 'external/*' }], revision: 'external' }); + await render(true, 1); + await render(false, 1); + await render(true, 1); + await openList(); + expect(resource().value).toBe('draft/*'); + expect(mocks.rules).toHaveBeenCalledTimes(1); + await click('projectPermissions.cancel'); + await editRule(); + expect(resource().value).toBe('src/*'); + await render(true, 2); + await editRule(); + expect(resource().value).toBe('external/*'); +}); + +it('shows retryable host failures instead of empty rules while retaining readable grants', async () => { + mocks.rules.mockRejectedValue(new Error('Host does not support project rules')); + await render(); + expect(container.textContent).toContain('Host does not support project rules'); + expect(container.textContent).toContain('docs/*'); + expect(container.textContent).not.toContain('projectPermissions.rulesEmpty'); + expect(button('projectPermissions.addAuthorization').disabled).toBe(true); + mocks.rules.mockResolvedValue({ rules: [rule], revision: 'v1' }); + await click('common:retry'); + await openList(); + await editRule(); + expect(resource().value).toBe('src/*'); + expect(container.textContent).not.toContain('Host does not support project rules'); +}); + +it('revokes grants only in the owning project and reloads their state', async () => { + await render(); + mocks.grants.mockResolvedValue([]); + await openList(); + await click('projectPermissions.removeGrant'); + expect(mocks.remove).toHaveBeenCalledExactlyOnceWith('owned', grant); + expect(document.querySelector('[role="table"]')?.textContent).not.toContain('docs/*'); + expect(container.querySelector('.project-resource-summary__value')?.textContent).toBe('1'); +}); + +it('does not apply confirmation from a previous device and ignores its delayed reads', async () => { + let finishRules!: (value: unknown) => void; + let confirm!: (value: boolean) => void; + mocks.rules.mockReturnValueOnce(new Promise(resolve => { finishRules = resolve; })); + await render(); + // Wait for the first read to settle before opening a mutation confirmation. + await act(async () => finishRules({ rules: [rule], revision: 'v1' })); + mocks.confirm.mockReturnValueOnce(new Promise(resolve => { confirm = resolve; })); + await openList(); + await click('projectPermissions.removeGrant'); + mocks.rules.mockReturnValueOnce(new Promise(resolve => { finishRules = resolve; })); + await render(true, 1); + mocks.rules.mockResolvedValue({ rules: [{ ...rule, resource: 'peer/*' }], revision: 'peer' }); + await act(async () => activateSurface('peer:other')); + await act(async () => { + confirm(true); + finishRules({ rules: [{ ...rule, resource: 'old-private/*' }], revision: 'old' }); + }); + expect(mocks.remove).not.toHaveBeenCalled(); + await openList(); + await editRule(); + expect(resource().value).toBe('peer/*'); +}); + +it('keeps the draft and revision intact after a conflicting save', async () => { + await render(); + mocks.save.mockRejectedValue(new Error('Rules changed outside OpenBitFun')); + await openList(); + await editRule(); + editResource('draft/*'); + await click('projectPermissions.saveRules'); + expect(resource().value).toBe('draft/*'); + expect(mocks.error).toHaveBeenCalledWith('projectPermissions.rulesConflict'); + expect(button('projectPermissions.saveRules').disabled).toBe(false); +}); + +it('does not fetch hidden panels or carry drafts across projects', async () => { + await render(false); + expect(mocks.rules).not.toHaveBeenCalled(); + await render(); + await openList(); + await editRule(); + editResource('private/*'); + await render(true, 0, { ...workspace, id: 'other', rootPath: '/other' }); + await openList(); + await editRule(); + expect(resource().value).toBe('src/*'); + expect(mocks.rules).toHaveBeenLastCalledWith('other'); + expect(button('projectPermissions.saveRules')).toBeUndefined(); +}); + +it('keeps the direct dialog entry compatible with the shared list editor', async () => { + await act(async () => root.render()); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain('Owned project'); + expect(dialog.textContent).toContain('projectPermissions.grantsTitle'); + await editRule(); + expect(dialog.querySelector('input')?.value).toBe('src/*'); +}); + +it('opens a separate creation dialog from the empty action and saves only the completed authorization', async () => { + mocks.grants.mockResolvedValue([]); + mocks.rules.mockResolvedValue({ rules: [], revision: 'empty' }); + await render(); + const pane = container.querySelector('.project-resource-summary__pane:not(button)')!; + expect(pane.querySelector('.project-resource-summary__title')).toBeNull(); + expect(pane.textContent).toBe('projectPermissions.addAuthorization'); + expect(container.querySelector('.workspace-project-permissions-dialog__heading button')).toBeNull(); + await act(async () => pane.querySelector('button')!.click()); + expect(resource().value).toBe(''); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(document.querySelector('[role="table"]')).toBeNull(); + expect(document.querySelector('button[type="submit"]')!.disabled).toBe(true); + expect(mocks.save).not.toHaveBeenCalled(); + const action = document.querySelector('select[aria-label="projectPermissions.action"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(action, 'bash'); + action.dispatchEvent(new Event('change', { bubbles: true })); + }); + editResource('pnpm test'); + await act(async () => document.querySelector('button[type="submit"]')!.click()); + expect(mocks.save).toHaveBeenCalledExactlyOnceWith('owned', [{ action: 'bash', resource: 'pnpm test', effect: 'allow' }], 'empty'); + expect(container.querySelector('.project-resource-summary__value')?.textContent).toBe('1'); + expect(container.querySelector('.project-resource-summary__empty')).toBeNull(); + expect(container.querySelector('.workspace-project-permissions-dialog__heading button')?.textContent).toBe('projectPermissions.addAuthorization'); +}); + + +it('cancels standalone addition without changing saved permissions and retains input after a failure', async () => { + await render(); + await click('projectPermissions.addAuthorization'); + editResource('discarded/*'); + await click('projectPermissions.cancel'); + expect(mocks.save).not.toHaveBeenCalled(); + expect(container.querySelector('.project-resource-summary__value')?.textContent).toBe('2'); + await click('projectPermissions.addAuthorization'); + expect(resource().value).toBe(''); + await fillNewPermission('new/*'); + mocks.save.mockRejectedValueOnce(new Error('Host offline')); + await act(async () => document.querySelector('button[type="submit"]')!.click()); + expect(resource().value).toBe('new/*'); + expect(document.body.textContent).toContain('projectPermissions.rulesSaveFailed'); + expect(document.querySelector('[role="table"]')).toBeNull(); + await act(async () => document.querySelector('button[type="submit"]')!.click()); + expect(mocks.save).toHaveBeenLastCalledWith('owned', [rule, { action: 'read', resource: 'new/*', effect: 'allow' }], 'v1'); +}); + +it('stages a completed addition from the list without inserting a blank rule or saving other drafts', async () => { + await render(); + await openList(); + const list = document.querySelector('[role="dialog"]')!; + await act(async () => button('projectPermissions.addAuthorization', list).click()); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(2); + expect(list.querySelectorAll('[data-openbitfun-part="rule"]')).toHaveLength(1); + await fillNewPermission('staged/*'); + await act(async () => document.querySelector('button[type="submit"]')!.click()); + expect(mocks.save).not.toHaveBeenCalled(); + expect(list.querySelectorAll('[data-openbitfun-part="rule"]')).toHaveLength(2); + await click('projectPermissions.saveRules'); + expect(mocks.save).toHaveBeenLastCalledWith('owned', [rule, { action: 'read', resource: 'staged/*', effect: 'allow' }], 'v1'); +}); + +async function fillNewPermission(value: string) { + const action = document.querySelector('select[aria-label="projectPermissions.action"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(action, 'read'); + action.dispatchEvent(new Event('change', { bubbles: true })); + }); + editResource(value); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathAddDialog.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathAddDialog.tsx new file mode 100644 index 0000000000..76aa862dcc --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathAddDialog.tsx @@ -0,0 +1,136 @@ +import { useEffect, useId, useRef, useState, useSyncExternalStore, type FormEvent } from 'react'; +import { Alert, Button, Dialog, DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, + DialogHeading, DialogTitle, Field, Icon, Input, Textarea } from '@openbitfun/ui'; +import { FolderOpen } from 'lucide-react'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { useI18n } from '@/infrastructure/i18n'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { sshApi } from '@/features/ssh-remote/sshApi'; +import RemoteFileBrowser from '@/features/ssh-remote/RemoteFileBrowser'; +import { isRemoteWorkspace, type RelatedPath, type WorkspaceInfo } from '@/shared/types'; +import './WorkspaceRelatedPathsDialog.scss'; + +export function WorkspaceRelatedPathAddDialog({ workspace, isOpen, onClose, onAdd }: { + workspace: WorkspaceInfo; + isOpen: boolean; + onClose: () => void; + /** The directory manager stages additions until its existing Save action. */ + onAdd?: (directory: RelatedPath) => void; +}) { + const { t } = useI18n('common'); + const { updateWorkspaceRelatedPaths } = useWorkspaceContext(); + const activeScope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); + const ownerScope = useRef(activeScope).current; + const latest = useRef({ workspace, isOpen }); + latest.current = { workspace, isOpen }; + const generation = useRef(0); + const pending = useRef(false); + const formId = useId(); + const [path, setPath] = useState(''); + const [description, setDescription] = useState(''); + const [saving, setSaving] = useState(false); + const [selecting, setSelecting] = useState(false); + const [browsing, setBrowsing] = useState(false); + const [homePath, setHomePath] = useState(); + const [error, setError] = useState(null); + const remote = isRemoteWorkspace(workspace); + const connectionId = workspace.connectionId?.trim(); + const busy = saving || selecting; + + useEffect(() => { + generation.current += 1; + pending.current = false; + setPath(''); setDescription(''); setError(null); setSaving(false); setSelecting(false); setBrowsing(false); + return () => { generation.current += 1; }; + }, [isOpen, workspace.id, workspace.rootPath, connectionId]); + + useEffect(() => { + setHomePath(undefined); + if (!isOpen || !remote || !connectionId || !ownerScope.isCurrent()) return; + let cancelled = false; + void sshApi.getServerInfo(connectionId).then(info => { + if (!cancelled && ownerScope.isCurrent()) setHomePath(info?.homeDir?.trim() || undefined); + }).catch(() => { /* The explicit project root remains a usable browsing start. */ }); + return () => { cancelled = true; }; + }, [connectionId, isOpen, ownerScope, remote]); + + const current = (version: number) => generation.current === version && ownerScope.isCurrent() + && latest.current.isOpen && latest.current.workspace.id === workspace.id; + const close = () => { if (!busy && ownerScope.isCurrent()) onClose(); }; + const chooseDirectory = async () => { + if (busy || !ownerScope.isCurrent()) return; + if (remote) { if (connectionId) setBrowsing(true); return; } + const version = generation.current; + setSelecting(true); + setError(null); + try { + const { pickWorkspaceDirectory } = await import('@/infrastructure/peer-device/pickWorkspaceDirectory'); + if (!current(version)) return; + const selected = await pickWorkspaceDirectory({ + title: t('nav.workspaces.relatedPaths.dialog.selectDirectoryTitle'), defaultPath: path || workspace.rootPath, + }); + if (current(version) && selected?.trim()) setPath(selected); + } catch { + if (current(version)) setError(t('nav.workspaces.relatedPaths.messages.selectFailed')); + } finally { + if (current(version)) setSelecting(false); + } + }; + const submit = async (event: FormEvent) => { + event.preventDefault(); + const version = generation.current; + if (pending.current || busy || !current(version) || !path.trim()) return; + const directory: RelatedPath = { path: path.trim(), ...(description.trim() ? { description: description.trim() } : {}) }; + pending.current = true; + setSaving(true); + setError(null); + try { + if (onAdd) onAdd(directory); + else await updateWorkspaceRelatedPaths(workspace.id, [...(latest.current.workspace.relatedPaths ?? []), directory]); + if (current(version)) onClose(); + } catch (failure) { + if (current(version)) setError(failure instanceof Error ? failure.message : t('nav.workspaces.relatedPaths.messages.saveFailed')); + } finally { + if (generation.current === version) pending.current = false; + if (current(version)) setSaving(false); + } + }; + + return { if (!open) close(); }} size="md"> + + + {t('nav.workspaces.relatedPaths.dialog.add')} + {remote && {t('nav.workspaces.relatedPaths.dialog.remoteScope', { + connectionName: workspace.connectionName || workspace.name, + })}} + + + + +
void submit(event)} + data-openbitfun-component="workspace-related-paths-dialog" data-openbitfun-part="addForm"> + } + onClick={() => void chooseDirectory()} disabled={busy || (remote && !connectionId)} loading={selecting}>{t('actions.select')}}> + { setPath(event.target.value); setError(null); }} size="sm" autoFocus disabled={busy} /> + + +