mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: recent servers list when linking the desktop app
This commit is contained in:
1 parent
cb12541c16
commit
25cbd40283
4 files changed
+107
-1
No files matched your search
@@ -1982,6 +1982,8 @@
|
|||||||
"wizard": {
|
"wizard": {
|
||||||
"title": "Link to a Termix server",
|
"title": "Link to a Termix server",
|
||||||
"reloginTitle": "Sign in again",
|
"reloginTitle": "Sign in again",
|
||||||
|
"savedServers": "Recent servers",
|
||||||
|
"removeServer": "Remove from the list",
|
||||||
"serverDescription": "Enter the address you use to open Termix in your browser.",
|
"serverDescription": "Enter the address you use to open Termix in your browser.",
|
||||||
"signinDescription": "Sign in to {{url}}",
|
"signinDescription": "Sign in to {{url}}",
|
||||||
"chooseDescription": "This device already has data. What should happen to it?",
|
"chooseDescription": "This device already has data. What should happen to it?",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertCircle, Loader2 } from "lucide-react";
|
import { AlertCircle, Loader2, X } from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import { Switch } from "@/components/switch";
|
import { Switch } from "@/components/switch";
|
||||||
@@ -23,6 +23,11 @@ import {
|
|||||||
type SyncStatus,
|
type SyncStatus,
|
||||||
} from "@/api/sync-api";
|
} from "@/api/sync-api";
|
||||||
import { ProxySettingsFields } from "./ProxySettingsFields";
|
import { ProxySettingsFields } from "./ProxySettingsFields";
|
||||||
|
import {
|
||||||
|
forgetServerUrl,
|
||||||
|
getSavedServerUrls,
|
||||||
|
rememberServerUrl,
|
||||||
|
} from "./saved-servers";
|
||||||
|
|
||||||
type Step = "server" | "signin" | "choose" | "finishing";
|
type Step = "server" | "signin" | "choose" | "finishing";
|
||||||
|
|
||||||
@@ -69,6 +74,7 @@ export function LinkServerDialog({
|
|||||||
}: LinkServerDialogProps) {
|
}: LinkServerDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [step, setStep] = useState<Step>(relogin ? "signin" : "server");
|
const [step, setStep] = useState<Step>(relogin ? "signin" : "server");
|
||||||
|
const [savedUrls, setSavedUrls] = useState<string[]>(getSavedServerUrls);
|
||||||
const [target, setTarget] = useState<ServerTarget>({
|
const [target, setTarget] = useState<ServerTarget>({
|
||||||
serverUrl: relogin?.serverUrl ?? "",
|
serverUrl: relogin?.serverUrl ?? "",
|
||||||
customHeaders: [],
|
customHeaders: [],
|
||||||
@@ -157,6 +163,7 @@ export function LinkServerDialog({
|
|||||||
mode: chosen,
|
mode: chosen,
|
||||||
serverName,
|
serverName,
|
||||||
});
|
});
|
||||||
|
rememberServerUrl(target.serverUrl);
|
||||||
toast.success(t("sync.wizard.linked"));
|
toast.success(t("sync.wizard.linked"));
|
||||||
onLinked(status);
|
onLinked(status);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -268,6 +275,35 @@ export function LinkServerDialog({
|
|||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{t("sync.wizard.serverUrlHint")}
|
{t("sync.wizard.serverUrlHint")}
|
||||||
</p>
|
</p>
|
||||||
|
{!relogin && savedUrls.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 pt-1">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t("sync.wizard.savedServers")}
|
||||||
|
</span>
|
||||||
|
{savedUrls.map((url) => (
|
||||||
|
<div
|
||||||
|
key={url}
|
||||||
|
className="flex items-center border border-border hover:bg-muted"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 min-w-0 truncate px-2 py-1.5 text-left font-mono text-xs"
|
||||||
|
onClick={() => setTarget({ ...target, serverUrl: url })}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("sync.wizard.removeServer")}
|
||||||
|
className="px-2 py-1.5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setSavedUrls(forgetServerUrl(url))}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{problem && (
|
{problem && (
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Same key as 2.8, so a desktop keeps the servers it already used.
|
||||||
|
const SAVED_URLS_KEY = "termix_saved_server_urls";
|
||||||
|
const MAX_SAVED_URLS = 5;
|
||||||
|
|
||||||
|
export function getSavedServerUrls(): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(SAVED_URLS_KEY) ?? "[]");
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((url): url is string => typeof url === "string")
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(urls: string[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberServerUrl(url: string): string[] {
|
||||||
|
const trimmed = url.trim().replace(/\/+$/, "");
|
||||||
|
if (!trimmed) return getSavedServerUrls();
|
||||||
|
const urls = [
|
||||||
|
trimmed,
|
||||||
|
...getSavedServerUrls().filter((saved) => saved !== trimmed),
|
||||||
|
].slice(0, MAX_SAVED_URLS);
|
||||||
|
write(urls);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetServerUrl(url: string): string[] {
|
||||||
|
const urls = getSavedServerUrls().filter((saved) => saved !== url);
|
||||||
|
write(urls);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
forgetServerUrl,
|
||||||
|
getSavedServerUrls,
|
||||||
|
rememberServerUrl,
|
||||||
|
} from "@/settings/sync/saved-servers";
|
||||||
|
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
describe("saved server urls", () => {
|
||||||
|
it("keeps the newest five, without duplicates or trailing slashes", () => {
|
||||||
|
for (let i = 1; i <= 6; i++) rememberServerUrl(`https://s${i}.example/`);
|
||||||
|
rememberServerUrl("https://s4.example");
|
||||||
|
expect(getSavedServerUrls()).toEqual([
|
||||||
|
"https://s4.example",
|
||||||
|
"https://s6.example",
|
||||||
|
"https://s5.example",
|
||||||
|
"https://s3.example",
|
||||||
|
"https://s2.example",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets a server and survives bad storage", () => {
|
||||||
|
rememberServerUrl("https://a.example");
|
||||||
|
expect(forgetServerUrl("https://a.example")).toEqual([]);
|
||||||
|
localStorage.setItem("termix_saved_server_urls", "{broken");
|
||||||
|
expect(getSavedServerUrls()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user