Feat: 取消自动断线,回到首页后能查看项目的连线情况
This commit is contained in:
parent
ff1cb22475
commit
735b37a617
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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': {}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 h-8 rounded border text-sm bg-white ${hasAny ? "text-foreground" : "text-muted-foreground"}`}
|
||||
title={hasAny ? "已连线文件" : "未连线到任何文件"}
|
||||
>
|
||||
{hasAny ? `已连线 ${list.length}` : "未连线"}
|
||||
</button>
|
||||
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-opacity duration-150 absolute right-0 top-full mt-2 z-50 w-80 rounded-md border bg-white text-foreground shadow">
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium mb-2">连线状态</div>
|
||||
{list.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无连线。通过“导入 JSON”选择文件后将建立连线。</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{list.map((c) => (
|
||||
<div key={c.language} className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{c.language}</div>
|
||||
<div className="text-xs text-muted-foreground truncate" title={c.name}>
|
||||
{c.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 h-7 rounded border text-xs hover:bg-accent"
|
||||
onClick={() => disconnectLanguage(projectId, c.language)}
|
||||
>
|
||||
断开
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
注:出于隐私,浏览器不提供完整路径,仅显示文件名;刷新页面后连线不会自动恢复。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
title={hasAny ? "已连线文件" : "未连线到任何文件"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
hasAny ? "bg-green-500" : "bg-red-500"
|
||||
)}
|
||||
></span>
|
||||
{hasAny ? `已连线 ${list.length}` : "未连线"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="p-4">
|
||||
<div className="text-sm font-medium mb-2">连线状态</div>
|
||||
{list.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
暂无连线。通过“导入 JSON”选择文件后将建立连线。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{list.map((c) => (
|
||||
<div
|
||||
key={c.language}
|
||||
className="flex items-start justify-between gap-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{c.language}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={c.name}
|
||||
>
|
||||
{c.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 h-7 rounded border text-xs hover:bg-accent"
|
||||
onClick={() => disconnectLanguage(projectId, c.language)}
|
||||
>
|
||||
断开
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
注:出于隐私,浏览器不提供完整路径,仅显示文件名;刷新页面后连线不会自动恢复。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
|
@ -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 (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="h-14 px-2 md:px-4 from-blue-400 to-cyan-400 bg-linear-to-r text-white">
|
||||
|
|
|
|||
|
|
@ -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<Project | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const flags = useConnectionFlags();
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
|
|
@ -88,39 +91,52 @@ function App() {
|
|||
<div className="text-sm text-muted-foreground">暂无项目,创建一个开始吧。</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative"
|
||||
onClick={() => navigate(`/editor/${p.id}`)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="项目设置"
|
||||
className="absolute top-2 right-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentProject(p);
|
||||
setSettingsOpen(true);
|
||||
{projects.map((p) => {
|
||||
const hasConn = !!flags[p.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative"
|
||||
onClick={() => navigate(`/editor/${p.id}`)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`);
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
<div className="truncate font-medium pr-8">{p.name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">创建时间: {formatTime(p.createdAt)}</div>
|
||||
{p.preferences?.aiPrompt ? (
|
||||
<div className="mt-3 text-xs line-clamp-3 text-muted-foreground">
|
||||
偏好:{p.preferences.aiPrompt}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="项目设置"
|
||||
className="absolute top-2 right-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentProject(p);
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
<div className="truncate font-medium pr-8 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
hasConn ? "bg-green-500" : "bg-zinc-300"
|
||||
)}
|
||||
title={hasConn ? "已连线" : "未连线"}
|
||||
/>
|
||||
{p.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">创建时间: {formatTime(p.createdAt)}</div>
|
||||
{p.preferences?.aiPrompt ? (
|
||||
<div className="mt-3 text-xs line-clamp-3 text-muted-foreground">
|
||||
偏好:{p.preferences.aiPrompt}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ type Listener = () => void;
|
|||
// projectId -> Snapshot
|
||||
const state = new Map<string, Snapshot>();
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
const globalListeners = new Set<Listener>();
|
||||
let globalVersion = 0;
|
||||
let cachedFlags: Record<string, boolean> | 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<string, boolean> {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
globalListeners.add(l);
|
||||
return () => globalListeners.delete(l);
|
||||
},
|
||||
() => {
|
||||
if (cachedFlags && cachedFlagsVersion === globalVersion) {
|
||||
return cachedFlags;
|
||||
}
|
||||
const flags: Record<string, boolean> = {};
|
||||
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)");
|
||||
|
|
|
|||
Loading…
Reference in New Issue