mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -0,0 +1,419 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import {
|
||||
completeLink,
|
||||
getLinkPreview,
|
||||
probeServer,
|
||||
reloginLink,
|
||||
type ProbeProblem,
|
||||
type ServerTarget,
|
||||
type SyncStatus,
|
||||
} from "@/api/sync-api";
|
||||
import { ProxySettingsFields } from "./ProxySettingsFields";
|
||||
|
||||
type Step = "server" | "signin" | "choose" | "finishing";
|
||||
|
||||
interface LinkServerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLinked: (status: SyncStatus) => void;
|
||||
/** Sign back in to the server this desktop is already linked to. */
|
||||
relogin?: { serverUrl: string };
|
||||
}
|
||||
|
||||
function problemText(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
problem: ProbeProblem | undefined,
|
||||
message: string | undefined,
|
||||
): string {
|
||||
switch (problem) {
|
||||
case "invalid_url":
|
||||
return t("sync.problems.invalidUrl");
|
||||
case "tls_untrusted":
|
||||
return t("sync.problems.tlsUntrusted");
|
||||
case "basic_auth_required":
|
||||
return t("sync.problems.basicAuthRequired");
|
||||
case "proxy_login":
|
||||
return t("sync.problems.proxyLogin");
|
||||
case "not_termix":
|
||||
return t("sync.problems.notTermix");
|
||||
case "version_mismatch":
|
||||
return t("sync.problems.versionMismatch");
|
||||
case "different_account":
|
||||
return t("sync.problems.differentAccount");
|
||||
default:
|
||||
return message
|
||||
? t("sync.problems.unreachableWithReason", { reason: message })
|
||||
: t("sync.problems.unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkServerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onLinked,
|
||||
relogin,
|
||||
}: LinkServerDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>(relogin ? "signin" : "server");
|
||||
const [target, setTarget] = useState<ServerTarget>({
|
||||
serverUrl: relogin?.serverUrl ?? "",
|
||||
customHeaders: [],
|
||||
basicAuth: null,
|
||||
allowInvalidCertificate: false,
|
||||
});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
const [problemKind, setProblemKind] = useState<ProbeProblem | null>(null);
|
||||
const [serverName, setServerName] = useState<string | undefined>();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [mode, setMode] = useState<"merge" | "replace">("merge");
|
||||
const [finishError, setFinishError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep(relogin ? "signin" : "server");
|
||||
setProblem(null);
|
||||
setProblemKind(null);
|
||||
setToken(null);
|
||||
setFinishError(null);
|
||||
setMode("merge");
|
||||
if (relogin) {
|
||||
setTarget((current) => ({ ...current, serverUrl: relogin.serverUrl }));
|
||||
return;
|
||||
}
|
||||
// An older version of the app may have saved a server address.
|
||||
window.electronAPI
|
||||
?.invoke?.("get-previous-server-url")
|
||||
.then((result) => {
|
||||
const url = (result as { serverUrl?: string | null } | null)?.serverUrl;
|
||||
if (url) {
|
||||
setTarget((current) =>
|
||||
current.serverUrl ? current : { ...current, serverUrl: url },
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [open, relogin]);
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true);
|
||||
setProblem(null);
|
||||
setProblemKind(null);
|
||||
try {
|
||||
const result = await probeServer(target);
|
||||
if (!result.ok) {
|
||||
setProblemKind(result.problem ?? null);
|
||||
setProblem(problemText(t, result.problem, result.message));
|
||||
if (
|
||||
result.problem === "basic_auth_required" ||
|
||||
result.problem === "proxy_login"
|
||||
) {
|
||||
setShowAdvanced(true);
|
||||
}
|
||||
if (result.problem === "basic_auth_required" && !target.basicAuth) {
|
||||
setTarget((current) => ({
|
||||
...current,
|
||||
basicAuth: { username: "", password: "" },
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTarget((current) => ({
|
||||
...current,
|
||||
serverUrl: result.serverUrl ?? current.serverUrl,
|
||||
}));
|
||||
setServerName(result.name);
|
||||
setStep("signin");
|
||||
} catch {
|
||||
setProblem(problemText(t, "unreachable", undefined));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const finish = async (sessionToken: string, chosen: "merge" | "replace") => {
|
||||
setStep("finishing");
|
||||
setFinishError(null);
|
||||
try {
|
||||
const status = await completeLink({
|
||||
...target,
|
||||
token: sessionToken,
|
||||
mode: chosen,
|
||||
serverName,
|
||||
});
|
||||
toast.success(t("sync.wizard.linked"));
|
||||
onLinked(status);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
const data = (
|
||||
error as {
|
||||
response?: { data?: { problem?: ProbeProblem; message?: string } };
|
||||
}
|
||||
)?.response?.data;
|
||||
setFinishError(problemText(t, data?.problem, data?.message));
|
||||
setStep("choose");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignedIn = async (sessionToken: string | null) => {
|
||||
if (!sessionToken) return;
|
||||
if (relogin) {
|
||||
try {
|
||||
const status = await reloginLink(sessionToken);
|
||||
toast.success(t("sync.wizard.signedInAgain"));
|
||||
onLinked(status);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
const data = (
|
||||
error as {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
problem?: ProbeProblem;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
)?.response?.data;
|
||||
throw new Error(
|
||||
data?.error === "different_account"
|
||||
? t("sync.problems.differentAccount")
|
||||
: problemText(t, data?.problem, data?.message),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setToken(sessionToken);
|
||||
const preview = await getLinkPreview().catch(() => ({}));
|
||||
setCounts(preview);
|
||||
const total = Object.values(preview).reduce((sum, count) => sum + count, 0);
|
||||
if (total === 0) {
|
||||
await finish(sessionToken, "merge");
|
||||
return;
|
||||
}
|
||||
setStep("choose");
|
||||
};
|
||||
|
||||
const localTotal = Object.values(counts).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
const wide = step === "signin";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={`bg-card border border-border rounded-none ${
|
||||
wide ? "sm:max-w-4xl h-[80vh] flex flex-col p-0 gap-0" : "sm:max-w-lg"
|
||||
}`}
|
||||
// The sign-in step is a page from another origin in an iframe; clicks
|
||||
// inside it look like outside clicks to the dialog.
|
||||
onPointerDownOutside={(event) => wide && event.preventDefault()}
|
||||
onInteractOutside={(event) => wide && event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className={wide ? "sr-only" : undefined}>
|
||||
<DialogTitle>
|
||||
{relogin ? t("sync.wizard.reloginTitle") : t("sync.wizard.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "server" && t("sync.wizard.serverDescription")}
|
||||
{step === "signin" &&
|
||||
t("sync.wizard.signinDescription", { url: target.serverUrl })}
|
||||
{step === "choose" && t("sync.wizard.chooseDescription")}
|
||||
{step === "finishing" && t("sync.wizard.finishingDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "server" && (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleCheck();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-xs font-semibold"
|
||||
htmlFor="sync-server-url"
|
||||
>
|
||||
{t("sync.wizard.serverUrl")}
|
||||
</label>
|
||||
<Input
|
||||
id="sync-server-url"
|
||||
autoFocus
|
||||
className="rounded-none"
|
||||
placeholder="https://termix.example.com"
|
||||
value={target.serverUrl}
|
||||
onChange={(event) =>
|
||||
setTarget({ ...target, serverUrl: event.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("sync.wizard.serverUrlHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{problem && (
|
||||
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
|
||||
<AlertCircle className="size-4 shrink-0 text-destructive" />
|
||||
<span>{problem}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(problemKind === "tls_untrusted" ||
|
||||
target.allowInvalidCertificate) && (
|
||||
<div className="flex items-center justify-between gap-3 border border-border p-2.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("sync.proxy.allowInvalidCertificate")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.proxy.allowInvalidCertificateHint")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!target.allowInvalidCertificate}
|
||||
onCheckedChange={(checked) =>
|
||||
setTarget({ ...target, allowInvalidCertificate: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-start text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced((value) => !value)}
|
||||
>
|
||||
{showAdvanced
|
||||
? t("sync.wizard.hideProxySettings")
|
||||
: t("sync.wizard.showProxySettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<ProxySettingsFields
|
||||
customHeaders={target.customHeaders ?? []}
|
||||
basicAuth={target.basicAuth ?? null}
|
||||
onChange={(next) => setTarget({ ...target, ...next })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-none"
|
||||
disabled={checking || !target.serverUrl.trim()}
|
||||
>
|
||||
{checking && <Loader2 className="size-4 animate-spin" />}
|
||||
{t("sync.wizard.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "signin" && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<ElectronLoginForm
|
||||
serverUrl={target.serverUrl}
|
||||
targetPurpose="link"
|
||||
onAuthSuccess={handleSignedIn}
|
||||
onChangeServer={() =>
|
||||
relogin ? onOpenChange(false) : setStep("server")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "choose" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{finishError && (
|
||||
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
|
||||
<AlertCircle className="size-4 shrink-0 text-destructive" />
|
||||
<span>{finishError}</span>
|
||||
</div>
|
||||
)}
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: "merge",
|
||||
title: t("sync.wizard.mergeTitle"),
|
||||
body: t("sync.wizard.mergeBody", { count: localTotal }),
|
||||
},
|
||||
{
|
||||
value: "replace",
|
||||
title: t("sync.wizard.replaceTitle"),
|
||||
body: t("sync.wizard.replaceBody", { count: localTotal }),
|
||||
},
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setMode(option.value)}
|
||||
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
|
||||
mode === option.value
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{option.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.body}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-none"
|
||||
disabled={!token}
|
||||
onClick={() => token && void finish(token, mode)}
|
||||
>
|
||||
{t("sync.wizard.link")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "finishing" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin text-accent-brand" />
|
||||
{t("sync.wizard.linking")}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type { SyncBasicAuth, SyncProxyHeader } from "@/api/sync-api";
|
||||
|
||||
interface ProxySettingsFieldsProps {
|
||||
customHeaders: SyncProxyHeader[];
|
||||
basicAuth: SyncBasicAuth | null;
|
||||
onChange: (next: {
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a reverse proxy in front of the server wants: extra headers (a
|
||||
* Cloudflare Access service token, a gateway key) and basic auth.
|
||||
*/
|
||||
export function ProxySettingsFields({
|
||||
customHeaders,
|
||||
basicAuth,
|
||||
onChange,
|
||||
}: ProxySettingsFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setHeader = (index: number, patch: Partial<SyncProxyHeader>) => {
|
||||
onChange({
|
||||
customHeaders: customHeaders.map((header, i) =>
|
||||
i === index ? { ...header, ...patch } : header,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border border-border p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">
|
||||
{t("sync.proxy.headersTitle")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.proxy.headersHint")}
|
||||
</span>
|
||||
</div>
|
||||
{customHeaders.map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="rounded-none h-8 text-xs"
|
||||
placeholder={t("sync.proxy.headerName")}
|
||||
value={header.name}
|
||||
onChange={(event) =>
|
||||
setHeader(index, { name: event.target.value })
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
className="rounded-none h-8 text-xs"
|
||||
placeholder={
|
||||
header.set
|
||||
? t("sync.proxy.savedValue")
|
||||
: t("sync.proxy.headerValue")
|
||||
}
|
||||
value={header.value}
|
||||
onChange={(event) =>
|
||||
setHeader(index, { value: event.target.value })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("sync.proxy.removeHeader")}
|
||||
className="p-1.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
customHeaders: customHeaders.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
customHeaders: [...customHeaders, { name: "", value: "" }],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("sync.proxy.addHeader")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">
|
||||
{t("sync.proxy.basicAuthTitle")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.proxy.basicAuthHint")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!basicAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
basicAuth: checked ? { username: "", password: "" } : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{basicAuth && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="rounded-none h-8 text-xs"
|
||||
placeholder={t("sync.proxy.username")}
|
||||
value={basicAuth.username}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
basicAuth: { ...basicAuth, username: event.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
className="rounded-none h-8 text-xs"
|
||||
placeholder={
|
||||
basicAuth.set
|
||||
? t("sync.proxy.savedValue")
|
||||
: t("sync.proxy.password")
|
||||
}
|
||||
value={basicAuth.password}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
basicAuth: { ...basicAuth, password: event.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Cloud,
|
||||
CloudOff,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Switch } from "@/components/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
|
||||
import { notifySyncChanged } from "@/lib/linked-server";
|
||||
import { invalidateServerStatusCache } from "@/lib/hosts-request-cache";
|
||||
import {
|
||||
getSyncConflicts,
|
||||
getSyncErrors,
|
||||
retrySyncErrors,
|
||||
settleSyncConflict,
|
||||
syncNow,
|
||||
unlinkServer,
|
||||
updateSyncSettings,
|
||||
type SyncBasicAuth,
|
||||
type SyncConflictItem,
|
||||
type SyncEntityInfo,
|
||||
type SyncErrorItem,
|
||||
type SyncProxyHeader,
|
||||
type SyncStatus,
|
||||
} from "@/api/sync-api";
|
||||
import { LinkServerDialog } from "./LinkServerDialog";
|
||||
import { ProxySettingsFields } from "./ProxySettingsFields";
|
||||
|
||||
type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useRelativeTime() {
|
||||
const { t } = useTranslation();
|
||||
return (value: string | null | undefined) => {
|
||||
if (!value) return t("sync.never");
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - new Date(value).getTime()) / 1000),
|
||||
);
|
||||
if (seconds < 60) return t("sync.justNow");
|
||||
if (seconds < 3600)
|
||||
return t("sync.minutesAgo", { count: Math.round(seconds / 60) });
|
||||
if (seconds < 86400)
|
||||
return t("sync.hoursAgo", { count: Math.round(seconds / 3600) });
|
||||
return new Date(value).toLocaleString();
|
||||
};
|
||||
}
|
||||
|
||||
function entityLabel(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
entity: SyncEntityInfo,
|
||||
): string {
|
||||
return t(`sync.entities.${entity.type}`, { defaultValue: entity.type });
|
||||
}
|
||||
|
||||
export function SyncPanel() {
|
||||
const { t } = useTranslation();
|
||||
const status = useSyncStatus();
|
||||
const relativeTime = useRelativeTime();
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [reloginOpen, setReloginOpen] = useState(false);
|
||||
const [unlinkOpen, setUnlinkOpen] = useState(false);
|
||||
const [keepData, setKeepData] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<SyncConflictItem[]>([]);
|
||||
const [errors, setErrors] = useState<SyncErrorItem[]>([]);
|
||||
const [desktopSettings, setDesktopSettings] = useState<DesktopSettings>({
|
||||
defaultConnectionOrigin: "local",
|
||||
});
|
||||
const [proxyDraft, setProxyDraft] = useState<{
|
||||
customHeaders: SyncProxyHeader[];
|
||||
basicAuth: SyncBasicAuth | null;
|
||||
allowInvalidCertificate: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const apply = useCallback((next: SyncStatus) => {
|
||||
setSyncStatus(next);
|
||||
notifySyncChanged();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI
|
||||
?.invoke?.("get-desktop-settings")
|
||||
.then((settings) => {
|
||||
if (settings) setDesktopSettings(settings as DesktopSettings);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const conflictCount = status?.conflicts ?? 0;
|
||||
const errorCount = status?.errors ?? 0;
|
||||
useEffect(() => {
|
||||
if (!status?.linked) return;
|
||||
getSyncConflicts()
|
||||
.then(setConflicts)
|
||||
.catch(() => {});
|
||||
}, [status?.linked, conflictCount, status?.lastSyncAt]);
|
||||
useEffect(() => {
|
||||
if (!status?.linked) return;
|
||||
getSyncErrors()
|
||||
.then(setErrors)
|
||||
.catch(() => {});
|
||||
}, [status?.linked, errorCount, status?.lastSyncAt]);
|
||||
|
||||
const run = async (key: string, action: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await action();
|
||||
} catch {
|
||||
toast.error(t("sync.actionFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byOwner = new Map<string, SyncEntityInfo[]>();
|
||||
for (const entity of status?.entities ?? []) {
|
||||
const key = entity.owner === "core" ? "core" : entity.owner;
|
||||
const list = byOwner.get(key) ?? [];
|
||||
list.push(entity);
|
||||
byOwner.set(key, list);
|
||||
}
|
||||
return [...byOwner.entries()].sort(([a], [b]) =>
|
||||
a === "core" ? -1 : b === "core" ? 1 : a.localeCompare(b),
|
||||
);
|
||||
}, [status?.entities]);
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const linkDialog = (
|
||||
<LinkServerDialog
|
||||
open={linkOpen}
|
||||
onOpenChange={setLinkOpen}
|
||||
onLinked={apply}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!status.linked) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudOff className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-bold">{t("sync.unlinkedTitle")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("sync.unlinkedBody")}
|
||||
</p>
|
||||
<ul className="text-xs text-muted-foreground list-disc pl-4 flex flex-col gap-1">
|
||||
<li>{t("sync.unlinkedPointHosts")}</li>
|
||||
<li>{t("sync.unlinkedPointOffline")}</li>
|
||||
<li>{t("sync.unlinkedPointAccount")}</li>
|
||||
</ul>
|
||||
<Button
|
||||
className="self-start rounded-none"
|
||||
onClick={() => setLinkOpen(true)}
|
||||
>
|
||||
<Cloud className="size-4" />
|
||||
{t("sync.linkButton")}
|
||||
</Button>
|
||||
</div>
|
||||
{linkDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const state = status.status ?? "idle";
|
||||
const stateText =
|
||||
state === "syncing"
|
||||
? t("sync.state.syncing")
|
||||
: state === "offline"
|
||||
? t("sync.state.offline")
|
||||
: state === "error"
|
||||
? t("sync.state.error")
|
||||
: state === "signed_out"
|
||||
? t("sync.state.signedOut")
|
||||
: t("sync.state.synced", { time: relativeTime(status.lastSyncAt) });
|
||||
|
||||
const toggleType = (type: string, enabled: boolean) =>
|
||||
run(`type:${type}`, async () => {
|
||||
const disabled = new Set<string>(
|
||||
(status.entities ?? [])
|
||||
.filter((entity) => !entity.enabled)
|
||||
.map((entity) => entity.type),
|
||||
);
|
||||
if (enabled) disabled.delete(type);
|
||||
else disabled.add(type);
|
||||
apply(await updateSyncSettings({ disabledTypes: [...disabled] }));
|
||||
});
|
||||
|
||||
const handleOriginChange = async (origin: "local" | "remote") => {
|
||||
const next = { ...desktopSettings, defaultConnectionOrigin: origin };
|
||||
setDesktopSettings(next);
|
||||
await window.electronAPI?.invoke?.("save-desktop-settings", next);
|
||||
invalidateServerStatusCache();
|
||||
window.dispatchEvent(new CustomEvent("hosts:refresh"));
|
||||
};
|
||||
|
||||
const draft = proxyDraft ?? {
|
||||
customHeaders: status.customHeaders ?? [],
|
||||
basicAuth: status.basicAuth ?? null,
|
||||
allowInvalidCertificate: !!status.allowInvalidCertificate,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Cloud className="size-4 text-accent-brand mt-0.5 shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-bold truncate">
|
||||
{status.serverName || t("sync.server")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground break-all">
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
|
||||
{t("sync.account")}
|
||||
</span>
|
||||
<span className="font-semibold truncate">
|
||||
{status.account?.username || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
|
||||
{t("sync.status")}
|
||||
</span>
|
||||
<span
|
||||
className={`font-semibold flex items-center gap-1 ${
|
||||
state === "idle" || state === "syncing"
|
||||
? ""
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{state === "syncing" && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{stateText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{status.lastError && state !== "idle" && state !== "syncing" && (
|
||||
<p className="text-[10px] text-muted-foreground break-words">
|
||||
{status.lastError}
|
||||
</p>
|
||||
)}
|
||||
{!!status.pending && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("sync.pending", { count: status.pending })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state === "signed_out" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-[10px] rounded-none"
|
||||
onClick={() => setReloginOpen(true)}
|
||||
>
|
||||
{t("sync.signInAgain")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[10px] rounded-none"
|
||||
disabled={busy === "sync" || state === "syncing"}
|
||||
onClick={() =>
|
||||
run("sync", async () => {
|
||||
apply(await syncNow());
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "sync" || state === "syncing" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
{t("sync.syncNow")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[10px] rounded-none"
|
||||
onClick={() => {
|
||||
setKeepData(true);
|
||||
setUnlinkOpen(true);
|
||||
}}
|
||||
>
|
||||
<Unlink className="size-3" />
|
||||
{t("sync.unlink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflicts.length > 0 && (
|
||||
<Section
|
||||
title={t("sync.conflictsTitle")}
|
||||
description={t("sync.conflictsDescription")}
|
||||
>
|
||||
{conflicts.map((conflict) => (
|
||||
<div
|
||||
key={conflict.id}
|
||||
className="flex items-center justify-between gap-2 border border-border p-2"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{conflict.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t(`sync.entities.${conflict.entityType}`, {
|
||||
defaultValue: conflict.entityType,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[10px] rounded-none"
|
||||
disabled={busy === `conflict:${conflict.id}`}
|
||||
onClick={() =>
|
||||
run(`conflict:${conflict.id}`, async () => {
|
||||
await settleSyncConflict(conflict.id, "mine");
|
||||
refreshSyncStatus();
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("sync.keepMine")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[10px] rounded-none"
|
||||
disabled={busy === `conflict:${conflict.id}`}
|
||||
onClick={() =>
|
||||
run(`conflict:${conflict.id}`, async () => {
|
||||
await settleSyncConflict(conflict.id, "server");
|
||||
refreshSyncStatus();
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("sync.keepServer")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<Section
|
||||
title={t("sync.errorsTitle")}
|
||||
description={t("sync.errorsDescription")}
|
||||
>
|
||||
{errors.map((error) => (
|
||||
<div
|
||||
key={`${error.entityType}:${error.syncId}`}
|
||||
className="flex items-start gap-2 border border-border p-2"
|
||||
>
|
||||
<AlertTriangle className="size-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold">
|
||||
{t(`sync.entities.${error.entityType}`, {
|
||||
defaultValue: error.entityType,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground break-words">
|
||||
{error.reason === "permission"
|
||||
? t("sync.errorPermission")
|
||||
: error.reason}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="self-start h-7 text-[10px] rounded-none"
|
||||
disabled={busy === "retry"}
|
||||
onClick={() =>
|
||||
run("retry", async () => {
|
||||
apply(await retrySyncErrors());
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("sync.retry")}
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section
|
||||
title={t("sync.whatSyncs")}
|
||||
description={t("sync.whatSyncsDescription")}
|
||||
>
|
||||
{groups.map(([owner, entities]) => (
|
||||
<div key={owner} className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
{owner === "core"
|
||||
? t("sync.coreGroup")
|
||||
: (entities[0]?.ownerName ?? owner)}
|
||||
</span>
|
||||
{entities.map((entity) => (
|
||||
<div
|
||||
key={entity.type}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs">{entityLabel(t, entity)}</span>
|
||||
{!entity.onServer && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.notOnServer")}
|
||||
</span>
|
||||
)}
|
||||
{entity.onServer && !entity.onDevice && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.notOnDevice")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={entity.enabled}
|
||||
disabled={busy === `type:${entity.type}`}
|
||||
onCheckedChange={(checked) =>
|
||||
void toggleType(entity.type, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("sync.localOnlyHint")}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={t("sync.originTitle")}
|
||||
description={t("sync.originDescription")}
|
||||
>
|
||||
<div className="flex border border-border overflow-hidden w-fit">
|
||||
{(["local", "remote"] as const).map((origin) => (
|
||||
<button
|
||||
key={origin}
|
||||
type="button"
|
||||
onClick={() => void handleOriginChange(origin)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
desktopSettings.defaultConnectionOrigin === origin
|
||||
? "bg-accent-brand text-background"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{origin === "local"
|
||||
? t("sync.originLocal")
|
||||
: t("sync.originRemote")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={t("sync.proxy.title")}
|
||||
description={t("sync.proxy.description")}
|
||||
>
|
||||
<ProxySettingsFields
|
||||
customHeaders={draft.customHeaders}
|
||||
basicAuth={draft.basicAuth}
|
||||
onChange={(next) => setProxyDraft({ ...draft, ...next })}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs">
|
||||
{t("sync.proxy.allowInvalidCertificate")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.proxy.allowInvalidCertificateHint")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.allowInvalidCertificate}
|
||||
onCheckedChange={(checked) =>
|
||||
setProxyDraft({ ...draft, allowInvalidCertificate: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{proxyDraft && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="self-start h-7 text-[10px] rounded-none"
|
||||
disabled={busy === "proxy"}
|
||||
onClick={() =>
|
||||
run("proxy", async () => {
|
||||
apply(await updateSyncSettings(proxyDraft));
|
||||
setProxyDraft(null);
|
||||
toast.success(t("sync.proxy.saved"));
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<LinkServerDialog
|
||||
open={reloginOpen}
|
||||
onOpenChange={setReloginOpen}
|
||||
onLinked={apply}
|
||||
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
|
||||
/>
|
||||
|
||||
<Dialog open={unlinkOpen} onOpenChange={setUnlinkOpen}>
|
||||
<DialogContent className="bg-card border border-border rounded-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sync.unlinkTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("sync.unlinkDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: true,
|
||||
title: t("sync.unlinkKeepTitle"),
|
||||
body: t("sync.unlinkKeepBody"),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
title: t("sync.unlinkRemoveTitle"),
|
||||
body: t("sync.unlinkRemoveBody"),
|
||||
},
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
onClick={() => setKeepData(option.value)}
|
||||
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
|
||||
keepData === option.value
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{option.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.body}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={() => setUnlinkOpen(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="rounded-none"
|
||||
disabled={busy === "unlink"}
|
||||
onClick={() =>
|
||||
run("unlink", async () => {
|
||||
apply(await unlinkServer(keepData));
|
||||
setUnlinkOpen(false);
|
||||
toast.success(t("sync.unlinked"));
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
|
||||
{t("sync.unlink")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{linkDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user