mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
@@ -1,107 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server } from "lucide-react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog.tsx";
|
||||
|
||||
type DesktopSettings = {
|
||||
defaultConnectionOrigin: "local" | "remote";
|
||||
migrationNoticeAcknowledged?: boolean;
|
||||
};
|
||||
|
||||
// One-time notice for Electron installs that previously pointed the whole
|
||||
// app at a remote Termix server (the pre-2.6.0 architecture). That install
|
||||
// now runs a fully local, standalone backend by default -- the hosts,
|
||||
// credentials, and snippets that lived on the old remote server won't show
|
||||
// up here until the user explicitly turns on Remote Sync and reconnects to
|
||||
// that same server. A fresh install never had a legacy serverUrl, so this
|
||||
// never fires for anyone who didn't go through the old flow, and it only
|
||||
// ever shows once per install (tracked in desktop-settings.json).
|
||||
export function MigrationNoticeDialog({
|
||||
onOpenRemoteSync,
|
||||
}: {
|
||||
onOpenRemoteSync: (serverUrl: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [legacyServerUrl, setLegacyServerUrl] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all([
|
||||
window.electronAPI?.invoke?.("get-legacy-server-config") as Promise<{
|
||||
serverUrl: string | null;
|
||||
} | null>,
|
||||
window.electronAPI?.invoke?.("get-desktop-settings") as Promise<
|
||||
DesktopSettings | undefined
|
||||
>,
|
||||
])
|
||||
.then(([legacyConfig, settings]) => {
|
||||
if (cancelled) return;
|
||||
const serverUrl = legacyConfig?.serverUrl || null;
|
||||
if (serverUrl && !settings?.migrationNoticeAcknowledged) {
|
||||
setLegacyServerUrl(serverUrl);
|
||||
setOpen(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const acknowledge = async () => {
|
||||
setOpen(false);
|
||||
const settings = ((await window.electronAPI?.invoke?.(
|
||||
"get-desktop-settings",
|
||||
)) as DesktopSettings | undefined) ?? { defaultConnectionOrigin: "local" };
|
||||
await window.electronAPI?.invoke?.("save-desktop-settings", {
|
||||
...settings,
|
||||
migrationNoticeAcknowledged: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetUpSync = async () => {
|
||||
const url = legacyServerUrl || "";
|
||||
await acknowledge();
|
||||
onOpenRemoteSync(url);
|
||||
};
|
||||
|
||||
if (!legacyServerUrl) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && acknowledge()}>
|
||||
<DialogContent className="bg-card border border-border max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="size-4 text-accent-brand" />
|
||||
<DialogTitle>{t("migrationNotice.title")}</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-sm text-foreground/90 pt-2 space-y-2">
|
||||
<p>{t("migrationNotice.body1")}</p>
|
||||
<p>{t("migrationNotice.body2", { url: legacyServerUrl })}</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex items-center gap-2 sm:justify-between">
|
||||
<Button variant="outline" size="sm" onClick={acknowledge}>
|
||||
{t("migrationNotice.dismiss")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSetUpSync}>
|
||||
{t("migrationNotice.setUpSync")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
interface RemoteSyncStatus {
|
||||
connected: boolean;
|
||||
syncing: boolean;
|
||||
lastSyncedAt: string | null;
|
||||
lastError: string | null;
|
||||
needsReauth: boolean;
|
||||
}
|
||||
|
||||
// Non-blocking banner shown when a connected remote sync server needs
|
||||
// re-authentication. Never gates or hides any other UI -- the local app
|
||||
// keeps working fully regardless of remote sync state.
|
||||
export function RemoteSyncBanner({ onReconnect }: { onReconnect: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
window.electronAPI
|
||||
?.invoke?.("get-remote-sync-status")
|
||||
.then((s) => setStatus((s as RemoteSyncStatus) ?? null))
|
||||
.catch(() => {});
|
||||
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
|
||||
(nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
|
||||
);
|
||||
return () => unsubscribe?.();
|
||||
}, []);
|
||||
|
||||
if (!status?.connected || !status.needsReauth) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||
<span>{t("remoteSync.bannerMessage")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReconnect}
|
||||
className="font-bold text-accent-brand hover:text-accent-brand/70 transition-colors shrink-0"
|
||||
>
|
||||
{t("remoteSync.bannerReconnect")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rem, remScale } from "@/lib/rem";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -212,7 +213,8 @@ function MasonryTile({
|
||||
const el = innerRef.current;
|
||||
if (!el) return;
|
||||
const report = () => {
|
||||
const h = fixedHeight ?? el.getBoundingClientRect().height;
|
||||
// Row units are Normal-size pixels; a measured height is scaled back.
|
||||
const h = fixedHeight ?? el.getBoundingClientRect().height / remScale();
|
||||
onReportRows(heightToRowSpan(h, ROW_UNIT, ROW_GAP));
|
||||
};
|
||||
report();
|
||||
@@ -247,7 +249,7 @@ function MasonryTile({
|
||||
"min-h-0",
|
||||
fixedHeight != null && "overflow-hidden [&>*]:h-full",
|
||||
)}
|
||||
style={fixedHeight != null ? { height: fixedHeight } : undefined}
|
||||
style={fixedHeight != null ? { height: rem(fixedHeight) } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -358,10 +360,16 @@ export function CardGridCanvas({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startY = e.clientY;
|
||||
// Heights are stored in Normal-size pixels, whatever the interface size.
|
||||
const scale = remScale();
|
||||
const startH =
|
||||
tileRefs.current.get(id)?.getBoundingClientRect().height ?? 120;
|
||||
(tileRefs.current.get(id)?.getBoundingClientRect().height ??
|
||||
120 * scale) / scale;
|
||||
const onMove = (ev: globalThis.MouseEvent) => {
|
||||
onChange(setHeight(slots, id, startH + (ev.clientY - startY)), columns);
|
||||
onChange(
|
||||
setHeight(slots, id, startH + (ev.clientY - startY) / scale),
|
||||
columns,
|
||||
);
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
@@ -396,10 +404,10 @@ export function CardGridCanvas({
|
||||
className="grid items-start"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,
|
||||
gridAutoRows: `${ROW_UNIT}px`,
|
||||
gridAutoRows: rem(ROW_UNIT),
|
||||
gridAutoFlow: "row dense",
|
||||
columnGap: `${ROW_GAP}px`,
|
||||
rowGap: `${ROW_GAP}px`,
|
||||
columnGap: rem(ROW_GAP),
|
||||
rowGap: rem(ROW_GAP),
|
||||
}}
|
||||
>
|
||||
{sorted.map((slot) => {
|
||||
|
||||
@@ -21,6 +21,8 @@ type Select2Props = Omit<
|
||||
"value" | "defaultValue" | "onChange" | "required"
|
||||
> & {
|
||||
placeholder?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
function getText(children: React.ReactNode): string {
|
||||
@@ -85,6 +87,8 @@ function Select2({
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
align = "start",
|
||||
contentClassName,
|
||||
name,
|
||||
id,
|
||||
required,
|
||||
@@ -171,8 +175,11 @@ function Select2({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="z-[99999] w-[var(--radix-popover-trigger-width)] min-w-52 p-0"
|
||||
align={align}
|
||||
className={cn(
|
||||
"z-[99999] w-[var(--radix-popover-trigger-width)] min-w-52 p-0",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center border-b border-border px-2">
|
||||
<Search className="mr-2 size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import React from "react";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { ExternalLink, Download, AlertTriangle, Info } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface VersionAlertProps {
|
||||
updateInfo: {
|
||||
success: boolean;
|
||||
status?: "up_to_date" | "requires_update" | "beta";
|
||||
localVersion?: string;
|
||||
remoteVersion?: string;
|
||||
latest_release?: {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
body: string;
|
||||
};
|
||||
cached?: boolean;
|
||||
cache_age?: number;
|
||||
error?: string;
|
||||
};
|
||||
onDownload?: () => void;
|
||||
}
|
||||
|
||||
export function VersionAlert({ updateInfo, onDownload }: VersionAlertProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!updateInfo.success) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{t("versionCheck.error")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{updateInfo.error || t("versionCheck.checkFailed")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (updateInfo.status === "up_to_date") {
|
||||
return (
|
||||
<Alert>
|
||||
<Download className="h-4 w-4" />
|
||||
<AlertTitle>{t("versionCheck.upToDate")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("versionCheck.currentVersion", {
|
||||
version: updateInfo.localVersion,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (updateInfo.status === "beta") {
|
||||
return (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>{t("versionCheck.betaVersion")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("versionCheck.betaVersionDesc", {
|
||||
current: updateInfo.localVersion,
|
||||
latest: updateInfo.remoteVersion,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (updateInfo.status === "requires_update") {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{t("versionCheck.updateAvailable")}</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<div>
|
||||
{t("versionCheck.newVersionAvailable", {
|
||||
current: updateInfo.localVersion,
|
||||
latest: updateInfo.remoteVersion,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{updateInfo.latest_release && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="font-medium">
|
||||
{updateInfo.latest_release.name}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{t("versionCheck.releasedOn", {
|
||||
date: new Date(
|
||||
updateInfo.latest_release.published_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{updateInfo.latest_release?.html_url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (onDownload) {
|
||||
onDownload();
|
||||
} else {
|
||||
window.open(updateInfo.latest_release!.html_url, "_blank");
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("versionCheck.downloadUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user