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}`);
- }}
- >
-
)}
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)");