diff --git a/package.json b/package.json index e6bcbaa..d4f3889 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b2c207..31657ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tailwindcss/vite': specifier: ^4.1.16 version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -702,6 +705,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -774,6 +790,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -2390,6 +2419,26 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.2)(react@19.2.0)': dependencies: react: 19.2.0 @@ -2444,6 +2493,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/rect@1.1.1': {} '@rolldown/pluginutils@1.0.0-beta.43': {} diff --git a/src/components/biz/header-connection-indicator.tsx b/src/components/biz/header-connection-indicator.tsx index d31bc9b..c12486f 100644 --- a/src/components/biz/header-connection-indicator.tsx +++ b/src/components/biz/header-connection-indicator.tsx @@ -1,54 +1,75 @@ -import { disconnectLanguage, useFileConnections } from "@/store/file-connection"; +import { + disconnectLanguage, + useFileConnections, +} from "@/store/file-connection"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { Button } from "../ui/button"; +import { cn } from "@/lib/utils"; type Props = { - projectId: string; + projectId: string; }; export function HeaderConnectionIndicator({ projectId }: Props) { - const snap = useFileConnections(projectId); - const list = Object.values(snap.connections); - const hasAny = list.length > 0; + const snap = useFileConnections(projectId); + const list = Object.values(snap.connections); + const hasAny = list.length > 0; - return ( -
- -
-
-
连线状态
- {list.length === 0 ? ( -
暂无连线。通过“导入 JSON”选择文件后将建立连线。
- ) : ( -
- {list.map((c) => ( -
-
-
{c.language}
-
- {c.name} -
-
- -
- ))} -
- 注:出于隐私,浏览器不提供完整路径,仅显示文件名;刷新页面后连线不会自动恢复。 -
-
- )} -
-
-
- ); + return ( + + + + + +
连线状态
+ {list.length === 0 ? ( +
+ 暂无连线。通过“导入 JSON”选择文件后将建立连线。 +
+ ) : ( +
+ {list.map((c) => ( +
+
+
+ {c.language} +
+
+ {c.name} +
+
+ +
+ ))} +
+ 注:出于隐私,浏览器不提供完整路径,仅显示文件名;刷新页面后连线不会自动恢复。 +
+
+ )} +
+
+ ); } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..715bf76 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index 8c1f8a7..07a63b9 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -38,7 +38,7 @@ import { useClipboard } from "@/hooks/use-clipboard"; import { toast } from "sonner"; import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; -import { clearAllConnections, useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection"; +import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection"; import { generateLanguageJson } from "@/lib/utils"; import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator"; import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button"; @@ -457,14 +457,6 @@ export default function Editor() { }; }, [projectId, languages]); - useEffect(() => { - return () => { - if (projectId) { - clearAllConnections(projectId); - } - }; - }, [projectId]); - return (
diff --git a/src/pages/home.tsx b/src/pages/home.tsx index a92fc4c..c6eece7 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button"; import { createProject, deleteProjectDeep, listProjects, type Project, updateProject } from "@/lib/db"; import { ProjectSettingsModal } from "@/components/biz/project-settings-modal"; import { Settings } from "lucide-react"; +import { useConnectionFlags } from "@/store/file-connection"; +import { cn } from "@/lib/utils"; function formatTime(ts: number) { try { @@ -23,6 +25,7 @@ function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [currentProject, setCurrentProject] = useState(null); const navigate = useNavigate(); + const flags = useConnectionFlags(); async function refresh() { setLoading(true); @@ -88,39 +91,52 @@ function App() {
暂无项目,创建一个开始吧。
) : (
- {projects.map((p) => ( -
navigate(`/editor/${p.id}`)} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`); - }} - > - -
{p.name}
-
ID: {p.id}
-
创建时间: {formatTime(p.createdAt)}
- {p.preferences?.aiPrompt ? ( -
- 偏好:{p.preferences.aiPrompt} + +
+ + {p.name}
- ) : null} -
- ))} +
ID: {p.id}
+
创建时间: {formatTime(p.createdAt)}
+ {p.preferences?.aiPrompt ? ( +
+ 偏好:{p.preferences.aiPrompt} +
+ ) : null} +
+ ); + })}
)}
diff --git a/src/store/file-connection.ts b/src/store/file-connection.ts index 9d8eccb..9374641 100644 --- a/src/store/file-connection.ts +++ b/src/store/file-connection.ts @@ -16,6 +16,10 @@ type Listener = () => void; // projectId -> Snapshot const state = new Map(); const listeners = new Map>(); +const globalListeners = new Set(); +let globalVersion = 0; +let cachedFlags: Record | null = null; +let cachedFlagsVersion = -1; function ensureProject(projectId: string) { if (!state.has(projectId)) { @@ -28,8 +32,18 @@ function ensureProject(projectId: string) { function emit(projectId: string) { const set = listeners.get(projectId); - if (!set) return; - for (const l of Array.from(set)) { + if (set) { + for (const l of Array.from(set)) { + try { + l(); + } catch { + // ignore + } + } + } + // bump global version and invalidate cache + globalVersion += 1; + for (const l of Array.from(globalListeners)) { try { l(); } catch { @@ -56,6 +70,28 @@ export function isFilePickerSupported(): boolean { return typeof window !== "undefined" && typeof (window as unknown as { showOpenFilePicker?: unknown }).showOpenFilePicker === "function"; } +export function useConnectionFlags(): Record { + return useSyncExternalStore( + (l) => { + globalListeners.add(l); + return () => globalListeners.delete(l); + }, + () => { + if (cachedFlags && cachedFlagsVersion === globalVersion) { + return cachedFlags; + } + const flags: Record = {}; + for (const [pid, snap] of state.entries()) { + flags[pid] = Object.keys(snap.connections).length > 0; + } + cachedFlags = flags; + cachedFlagsVersion = globalVersion; + return cachedFlags; + }, + () => ({}) + ); +} + export async function connectLanguageToFile(projectId: string, language: string): Promise<{ text: string; connection: LanguageConnection } | null> { if (!isFilePickerSupported()) { toast.error("当前浏览器不支持文件系统访问 API(showOpenFilePicker)");