From 08cb1345231e78b80366d0dfb5a0dd7536742032 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Fri, 24 Jul 2026 23:00:30 +0800 Subject: [PATCH] refactor(ui): move shared primitives into component layer --- .../SharedComponentOwnershipBatch.md | 49 +++ package.json | 1 + .../quality/check-component-boundaries.mjs | 87 +++++ .../FileTreeContent/FileTreeRows.tsx | 2 +- src/components/FileTreeContent/index.tsx | 4 +- src/components/FolderHeaderRow.tsx | 56 +++ .../useGlobalDragDrop/utils/dragDetection.ts | 6 +- .../ListPanel/ListPanelScrollArea.tsx | 23 ++ .../ListPanel/ListPanelTabPillRow.tsx | 19 + src/components/ListPanel/MenuPanel.tsx | 6 +- src/components/Placeholder.tsx | 241 +++++++++++++ src/components/SearchSortBar.tsx | 142 ++++++++ src/components/SettingsTable/index.tsx | 8 +- src/components/TeamMemberTable/index.tsx | 2 +- src/components/TreePanelSidebar/index.tsx | 6 +- .../VirtualizedStickyTree/index.tsx | 2 +- .../InputArea/components/QueuedMessages.tsx | 3 +- .../WorkStation/shared/FolderHeaderRow.tsx | 76 +--- .../layouts/blocks/ListPanelScrollArea.tsx | 34 +- .../blocks/ListPanelTabPillRow/index.tsx | 27 +- .../layouts/blocks/Placeholder/index.tsx | 324 +----------------- .../shared/layouts/blocks/SearchSortBar.tsx | 154 +-------- src/shared/dnd/dragSideChannel.ts | 3 + 23 files changed, 658 insertions(+), 617 deletions(-) create mode 100644 docs/frontend-ui-audit-2026-07-22/SharedComponentOwnershipBatch.md create mode 100644 scripts/quality/check-component-boundaries.mjs create mode 100644 src/components/FolderHeaderRow.tsx create mode 100644 src/components/ListPanel/ListPanelScrollArea.tsx create mode 100644 src/components/ListPanel/ListPanelTabPillRow.tsx create mode 100644 src/components/Placeholder.tsx create mode 100644 src/components/SearchSortBar.tsx diff --git a/docs/frontend-ui-audit-2026-07-22/SharedComponentOwnershipBatch.md b/docs/frontend-ui-audit-2026-07-22/SharedComponentOwnershipBatch.md new file mode 100644 index 000000000..45608c0a8 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-22/SharedComponentOwnershipBatch.md @@ -0,0 +1,49 @@ +# Frontend UI Audit — Shared Component Ownership Batch + +**Files:** `src/components/Placeholder.tsx`, `src/components/SearchSortBar.tsx`, `src/components/FolderHeaderRow.tsx`, `src/components/ListPanel/ListPanelScrollArea.tsx`, `src/components/ListPanel/ListPanelTabPillRow.tsx`, `src/components/ListPanel/MenuPanel.tsx` +**Date:** 2026-07-22 +**Auditor:** ORGII agent session + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ----------------------------- | ----------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `FolderHeaderRow.tsx:32` | Collapsible full-row ` + {actions &&
{actions}
} + + ) +); + +FolderHeaderRow.displayName = "FolderHeaderRow"; diff --git a/src/components/GlobalDragDrop/useGlobalDragDrop/utils/dragDetection.ts b/src/components/GlobalDragDrop/useGlobalDragDrop/utils/dragDetection.ts index 2436e29cd..e608630ca 100644 --- a/src/components/GlobalDragDrop/useGlobalDragDrop/utils/dragDetection.ts +++ b/src/components/GlobalDragDrop/useGlobalDragDrop/utils/dragDetection.ts @@ -5,10 +5,10 @@ */ import type { MutableRefObject } from "react"; -import { reorderActiveRef } from "@src/engines/ChatPanel/InputArea/components/QueuedMessages"; import { isInternalFileTreeDragActive, isWorkstationTabDragActive, + messageQueueReorderActiveRef, } from "@src/shared/dnd/dragSideChannel"; import { getNativeFrameScale } from "@src/util/platform/tauri/nativeFrame"; @@ -22,7 +22,7 @@ export function isInternalDrag( const dragEvent = event as DragEvent; // Queue reorder drag is always internal - if (reorderActiveRef.current) { + if (messageQueueReorderActiveRef.current) { return true; } @@ -247,7 +247,7 @@ export function createPreventDefaults( ): (e: Event) => void { return (event: Event) => { // Queue reorder drag — never intercept - if (reorderActiveRef.current) return; + if (messageQueueReorderActiveRef.current) return; const dragEvent = event as DragEvent; const rawTypes = dragEvent.dataTransfer?.types; diff --git a/src/components/ListPanel/ListPanelScrollArea.tsx b/src/components/ListPanel/ListPanelScrollArea.tsx new file mode 100644 index 000000000..6139ee869 --- /dev/null +++ b/src/components/ListPanel/ListPanelScrollArea.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import { LIST_PANEL_SCROLL_AREA } from "./tokens"; + +export interface ListPanelScrollAreaProps { + children: React.ReactNode; + listPaddingTop?: "default" | "none"; + className?: string; +} + +const ListPanelScrollArea: React.FC = ({ + children, + listPaddingTop = "default", + className = "", +}) => ( +
+ {children} +
+); + +export default ListPanelScrollArea; diff --git a/src/components/ListPanel/ListPanelTabPillRow.tsx b/src/components/ListPanel/ListPanelTabPillRow.tsx new file mode 100644 index 000000000..b1396196a --- /dev/null +++ b/src/components/ListPanel/ListPanelTabPillRow.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +export interface ListPanelTabPillRowProps { + children: React.ReactNode; + className?: string; +} + +const ListPanelTabPillRow: React.FC = ({ + children, + className = "", +}) => ( +
+ {children} +
+); + +export default ListPanelTabPillRow; diff --git a/src/components/ListPanel/MenuPanel.tsx b/src/components/ListPanel/MenuPanel.tsx index 6f2acc594..232c3975b 100644 --- a/src/components/ListPanel/MenuPanel.tsx +++ b/src/components/ListPanel/MenuPanel.tsx @@ -10,11 +10,9 @@ import type { LucideIcon } from "lucide-react"; import React from "react"; +import ListPanelScrollArea from "@src/components/ListPanel/ListPanelScrollArea"; +import ListPanelTabPillRow from "@src/components/ListPanel/ListPanelTabPillRow"; import TabPill from "@src/components/TabPill"; -import { - ListPanelScrollArea, - ListPanelTabPillRow, -} from "@src/modules/shared/layouts/blocks"; import { getListIconClasses, getListItemClasses } from "./tokens"; diff --git a/src/components/Placeholder.tsx b/src/components/Placeholder.tsx new file mode 100644 index 000000000..ea75f35c2 --- /dev/null +++ b/src/components/Placeholder.tsx @@ -0,0 +1,241 @@ +import { Loader2 } from "lucide-react"; +import React, { memo, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; +import { TYPOGRAPHY } from "@src/config/workstation/tokens"; + +export const PLACEHOLDER_TOKENS = { iconSize: 32 } as const; +const LOADING_PLACEHOLDER_DEBOUNCE_MS = 250; +type BaseVariant = "empty" | "loading" | "error" | "no-results"; +type ContentPresetVariant = + | "no-file" + | "no-tabs" + | "no-connection" + | "no-query"; +export type PlaceholderVariant = BaseVariant | ContentPresetVariant; +export type PlaceholderPlacement = "sidebar" | "detail-panel"; +const CONTENT_PRESET_VARIANTS = new Set([ + "no-file", + "no-tabs", + "no-connection", + "no-query", +]); + +export interface PlaceholderProps { + variant: PlaceholderVariant; + placement?: PlaceholderPlacement; + title?: string; + subtitle?: string; + action?: { + label: string; + onClick: () => void; + variant?: "primary" | "secondary"; + disabled?: boolean; + dataTestId?: string; + }; + onRetry?: () => void; + icon?: React.ReactNode; + fillParentHeight?: boolean; + className?: string; +} + +interface DefaultText { + title: string; + subtitle?: string; +} + +export const Placeholder: React.FC = memo( + ({ + variant, + placement, + title, + subtitle, + action, + onRetry, + icon, + fillParentHeight = false, + className = "", + }) => { + const { t } = useTranslation(); + const resolvedPlacement: PlaceholderPlacement = + placement ?? + (CONTENT_PRESET_VARIANTS.has(variant) ? "detail-panel" : "sidebar"); + const defaults: Record = { + empty: { title: t("placeholders.nothingHereYet") }, + loading: { title: t("status.loading") }, + error: { title: t("errors.failedToLoad") }, + "no-results": { title: t("placeholders.noMatchingResults") }, + "no-file": { + title: t("placeholders.noFileOpen"), + subtitle: t("placeholders.selectFileToEdit"), + }, + "no-tabs": { + title: t("placeholders.noTabsOpen"), + subtitle: t("placeholders.selectItemToStart"), + }, + "no-connection": { + title: t("placeholders.noDatabaseConnected"), + subtitle: t("placeholders.addConnectionToQuery"), + }, + "no-query": { + title: t("placeholders.noQueryResults"), + subtitle: t("placeholders.runQueryToSeeResults"), + }, + }; + const defaultText = defaults[variant]; + const resolvedTitle = title ?? defaultText.title; + const resolvedSubtitle = subtitle ?? defaultText.subtitle; + const isError = variant === "error"; + const isLoading = variant === "loading"; + const resolvedAction = + action ?? + (onRetry && isError + ? { label: t("actions.retry"), onClick: onRetry } + : undefined); + const isDetailPanel = resolvedPlacement === "detail-panel"; + const titleClass = isDetailPanel + ? TYPOGRAPHY.contentTitle + : TYPOGRAPHY.panelTitle; + const subtitleClass = isDetailPanel + ? TYPOGRAPHY.contentSubtitle + : TYPOGRAPHY.panelSubtitle; + const stretchClass = + fillParentHeight && isDetailPanel + ? "min-h-0 h-full w-full min-w-0 " + : fillParentHeight + ? "h-full w-full min-w-0 flex-1 " + : ""; + const containerClass = isDetailPanel + ? `${stretchClass}flex ${fillParentHeight ? "min-h-0" : "h-full"} w-full items-center justify-center ${className}`.trim() + : `${stretchClass}flex ${fillParentHeight ? "" : "h-full "}flex-col items-center justify-center gap-1 p-4 text-center ${className}`.trim(); + + if (isLoading) { + return ( + + ); + } + + if (isDetailPanel) { + return ( +
+
+ {icon && ( +
{icon}
+ )} +
+ {resolvedTitle} +
+ {resolvedSubtitle && ( +
+ {resolvedSubtitle} +
+ )} + {resolvedAction && ( + + )} +
+
+ ); + } + + return ( +
+ + {resolvedTitle} + + {resolvedSubtitle && ( + + {resolvedSubtitle} + + )} + {resolvedAction && ( + + )} +
+ ); + } +); +Placeholder.displayName = "Placeholder"; + +interface DebouncedLoadingSpinnerProps { + containerClass: string; + title: string; + subtitle?: string; + titleClass: string; + subtitleClass: string; + showLabel: boolean; +} + +const DebouncedLoadingSpinner: React.FC = memo( + ({ + containerClass, + title, + subtitle, + titleClass, + subtitleClass, + showLabel, + }) => { + const [showSpinner, setShowSpinner] = useState(false); + useEffect(() => { + const timer = window.setTimeout( + () => setShowSpinner(true), + LOADING_PLACEHOLDER_DEBOUNCE_MS + ); + return () => window.clearTimeout(timer); + }, []); + return ( +
+ {showSpinner && ( + <> + + {showLabel ? ( + <> + {title} + {subtitle ? ( + + {subtitle} + + ) : null} + + ) : null} + + )} +
+ ); + } +); +DebouncedLoadingSpinner.displayName = "DebouncedLoadingSpinner"; diff --git a/src/components/SearchSortBar.tsx b/src/components/SearchSortBar.tsx new file mode 100644 index 000000000..4674f91eb --- /dev/null +++ b/src/components/SearchSortBar.tsx @@ -0,0 +1,142 @@ +import { Filter, Search } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Input, { type InputProps } from "@src/components/Input"; +import Select from "@src/components/Select"; +import type { SelectOption } from "@src/components/Select"; + +export interface SearchSortBarFilterConfig { + pills: React.ReactNode; + expanded: boolean; + active: boolean; + onToggle: () => void; + title?: string; +} + +export interface SearchSortBarProps { + searchValue?: string; + searchPlaceholder?: string; + searchInputSize?: InputProps["size"]; + onSearchChange?: (value: string) => void; + onSearchClear?: () => void; + sortValue?: string; + sortOptions?: SelectOption[]; + onSortChange?: (value: string | number | (string | number)[]) => void; + searchCountText?: string; + sortWidthClassName?: string; + leftContent?: React.ReactNode; + rightContent?: React.ReactNode; + allowSearchClear?: boolean; + tabPills?: React.ReactNode; + filterConfig?: SearchSortBarFilterConfig; + noPadding?: boolean; +} + +const SearchSortBar: React.FC = ({ + searchValue, + searchPlaceholder, + searchInputSize, + onSearchChange, + onSearchClear, + sortValue, + sortOptions, + onSortChange, + searchCountText, + sortWidthClassName = "w-[180px]", + leftContent, + rightContent, + allowSearchClear = true, + tabPills, + filterConfig, + noPadding = false, +}) => { + const { t } = useTranslation(); + const showSort = + typeof sortValue === "string" && + Array.isArray(sortOptions) && + sortOptions.length > 0 && + typeof onSortChange === "function"; + const hasSearchInput = + searchValue !== undefined && + searchPlaceholder !== undefined && + typeof onSearchChange === "function"; + const effectiveRightContent = + rightContent ?? + (filterConfig ? ( +