mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
+3
-3174
File diff suppressed because it is too large.
Load diff
@@ -105,33 +105,3 @@ export async function getAuditLogActions(): Promise<{ actions: string[] }> {
|
||||
handleApiError(error, "fetch audit log actions");
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditForwardingSettings {
|
||||
url: string;
|
||||
hasToken: boolean;
|
||||
envConfigured: boolean;
|
||||
}
|
||||
|
||||
export async function getAuditForwarding(): Promise<AuditForwardingSettings> {
|
||||
try {
|
||||
const response = await authApi.get("/users/audit-forwarding");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get audit forwarding settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAuditForwarding(
|
||||
url: string,
|
||||
token?: string,
|
||||
): Promise<{ url: string; hasToken: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/audit-forwarding", {
|
||||
url,
|
||||
token,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update audit forwarding settings");
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,16 @@ export async function getLoginMethods(): Promise<PublicLoginMethod[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/** False when the login policy turned remember me off. Public. */
|
||||
export async function getRememberDeviceAllowed(): Promise<boolean> {
|
||||
try {
|
||||
const response = await authApi.get("/users/auth/methods");
|
||||
return response.data?.rememberDevice !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitLoginMethod(
|
||||
methodId: string,
|
||||
body: Record<string, unknown>,
|
||||
|
||||
@@ -336,113 +336,3 @@ export async function reorderCredentials(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectKeyType(
|
||||
privateKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/detect-key-type", {
|
||||
privateKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect key type");
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectPublicKeyType(
|
||||
publicKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/detect-public-key-type", {
|
||||
publicKey,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect public key type");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateKeyPair(
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/validate-key-pair", {
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate key pair");
|
||||
}
|
||||
}
|
||||
|
||||
export interface GeneratedPublicKey {
|
||||
success?: boolean;
|
||||
publicKey?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function generatePublicKeyFromPrivate(
|
||||
privateKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<GeneratedPublicKey> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/generate-public-key", {
|
||||
privateKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "generate public key from private key");
|
||||
}
|
||||
}
|
||||
|
||||
export interface GeneratedKeyPair {
|
||||
success: boolean;
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
keyType?: string;
|
||||
format?: string;
|
||||
algorithm?: string;
|
||||
keySize?: number;
|
||||
curve?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function generateKeyPair(
|
||||
keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256",
|
||||
keySize?: number,
|
||||
passphrase?: string,
|
||||
): Promise<GeneratedKeyPair> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/generate-key-pair", {
|
||||
keyType,
|
||||
keySize,
|
||||
passphrase,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "generate SSH key pair");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deployCredentialToHost(
|
||||
credentialId: number,
|
||||
targetHostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/credentials/${credentialId}/deploy-to-host`,
|
||||
{ targetHostId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "deploy credential to host");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
|
||||
/** A layout or theme plugin the server found but could not load. */
|
||||
export interface UnavailableLook {
|
||||
id: string;
|
||||
version: string | null;
|
||||
layouts: string[];
|
||||
themes: string[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** Everything the browser picks the layout and theme from. */
|
||||
export interface LookResponse {
|
||||
layoutId: string | null;
|
||||
themeId: string | null;
|
||||
/** Admin defaults. */
|
||||
defaults: { layout: string | null; theme: string | null };
|
||||
/** The default-look plugin's layout and theme. */
|
||||
defaultLook: { layout: string | null; theme: string | null };
|
||||
/** TERMIX_SAFE_LAYOUT is set on the server. */
|
||||
safeLayout: boolean;
|
||||
unavailable: UnavailableLook[];
|
||||
}
|
||||
|
||||
export async function getLook(): Promise<LookResponse> {
|
||||
const response = await authApi.get("/user-preferences/look");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** The user's own layout and theme; null follows the default. */
|
||||
export async function saveUserLook(look: {
|
||||
layoutId?: string | null;
|
||||
themeId?: string | null;
|
||||
}): Promise<void> {
|
||||
await authApi.put("/user-preferences", look);
|
||||
}
|
||||
|
||||
/** Admin only. Null clears a default. */
|
||||
export async function saveDefaultLook(defaults: {
|
||||
layout?: string | null;
|
||||
theme?: string | null;
|
||||
}): Promise<{ layout: string | null; theme: string | null }> {
|
||||
const response = await authApi.put(
|
||||
"/user-preferences/look/defaults",
|
||||
defaults,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -116,6 +116,10 @@ export interface UserPreferences {
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
/** Layout this user chose; null follows the admin default. */
|
||||
layoutId?: string | null;
|
||||
/** Theme this user chose; null follows the admin default. */
|
||||
themeId?: string | null;
|
||||
}
|
||||
|
||||
export function parseCustomKeybindings(
|
||||
|
||||
@@ -78,6 +78,22 @@ export interface PluginViewContribution {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface PluginLayoutContribution {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultTheme?: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginThemeContribution {
|
||||
id: string;
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
css: string;
|
||||
swatch?: { background: string; panel: string; text: string };
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
@@ -87,6 +103,8 @@ export interface PluginContributions {
|
||||
settings?: PluginSettingsContribution;
|
||||
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
|
||||
uiPresets?: PluginUiPresets;
|
||||
layouts?: PluginLayoutContribution[];
|
||||
themes?: PluginThemeContribution[];
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -236,6 +254,10 @@ export interface PluginSummary {
|
||||
assetVersion?: string | null;
|
||||
/** "en" plus the xx_YY names of shipped translations. */
|
||||
locales?: string[];
|
||||
/** Cache key for the theme CSS files; null without themes. */
|
||||
themeVersion?: string | null;
|
||||
/** The app is drawn with this plugin by default; it cannot be uninstalled. */
|
||||
defaultLook?: boolean;
|
||||
|
||||
tier?: string;
|
||||
source?: string;
|
||||
@@ -286,6 +308,8 @@ export interface RegistryPluginVersion {
|
||||
/** A beta, offered only to plugins on the beta channel. */
|
||||
prerelease?: boolean;
|
||||
compatible: boolean;
|
||||
/** The Termix range this version needs, when this build is too old. */
|
||||
needsTermix?: string | null;
|
||||
capabilities: string[];
|
||||
publishedAt?: string;
|
||||
releaseNotesUrl?: string;
|
||||
@@ -310,6 +334,8 @@ export interface RegistryPluginEntry {
|
||||
videoId?: string;
|
||||
features?: string[];
|
||||
docs?: string;
|
||||
/** The manifest's contributes keys, like layouts or themes. */
|
||||
contributes?: string[];
|
||||
versions: RegistryPluginVersion[];
|
||||
/** Newest release on the plugin's channel. */
|
||||
latestVersion: string | null;
|
||||
@@ -497,6 +523,15 @@ export async function uninstallPlugin(pluginId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts a bundled plugin back to the copy that shipped with this install. */
|
||||
export async function restoreBundledPlugin(pluginId: string): Promise<void> {
|
||||
try {
|
||||
await rbacApi.post(`${pluginPath(pluginId)}/restore-bundled`);
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewPluginState(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
|
||||
+27
-17
@@ -43,6 +43,33 @@ export async function updateSessionTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
export interface HttpsSettings {
|
||||
forceHttps: boolean;
|
||||
tlsEnabled: boolean;
|
||||
}
|
||||
|
||||
export async function getHttpsSettings(): Promise<HttpsSettings> {
|
||||
try {
|
||||
const response = await authApi.get("/users/https-settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch HTTPS settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHttpsSettings(
|
||||
forceHttps: boolean,
|
||||
): Promise<HttpsSettings> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/https-settings", {
|
||||
forceHttps,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update HTTPS settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BRANDING SETTINGS
|
||||
// ============================================================================
|
||||
@@ -54,12 +81,6 @@ export interface BrandingSettings {
|
||||
logo: string | null;
|
||||
}
|
||||
|
||||
export interface BrandingSettingsUpdate {
|
||||
appName?: string;
|
||||
tagline?: string;
|
||||
logo?: string | null;
|
||||
}
|
||||
|
||||
/** Public endpoint -- no auth required, so the login screen can read it. */
|
||||
export async function getBranding(): Promise<BrandingSettings> {
|
||||
try {
|
||||
@@ -69,14 +90,3 @@ export async function getBranding(): Promise<BrandingSettings> {
|
||||
handleApiError(error, "fetch branding settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBranding(
|
||||
update: BrandingSettingsUpdate,
|
||||
): Promise<BrandingSettings> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/branding", update);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update branding settings");
|
||||
}
|
||||
}
|
||||
@@ -70,54 +70,6 @@ export async function updateSSHHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkImportSSHHosts(
|
||||
hosts: SSHHostData[],
|
||||
overwrite = false,
|
||||
credentials?: Record<string, unknown>[],
|
||||
): Promise<{
|
||||
message: string;
|
||||
success: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/bulk-import", {
|
||||
hosts,
|
||||
overwrite,
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "bulk import SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function importSSHConfigHosts(
|
||||
content: string,
|
||||
overwrite = false,
|
||||
): Promise<{
|
||||
message: string;
|
||||
success: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/ssh-config-import", {
|
||||
content,
|
||||
overwrite,
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "import SSH config hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkUpdateSSHHosts(
|
||||
hostIds: number[],
|
||||
updates: Record<string, unknown>,
|
||||
@@ -134,6 +86,21 @@ export async function bulkUpdateSSHHosts(
|
||||
}
|
||||
}
|
||||
|
||||
export interface HostPolicy {
|
||||
manualCreate: boolean;
|
||||
/** The plugin that manages hosts, when hosts can't be added by hand. */
|
||||
blockedBy?: string;
|
||||
}
|
||||
|
||||
export async function getHostPolicy(): Promise<HostPolicy> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/policy");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "load host policy");
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderSSHHosts(
|
||||
positions: { id: number; sortOrder: number }[],
|
||||
): Promise<{ updated: number }> {
|
||||
@@ -167,44 +134,6 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportSSHHostWithCredentials(
|
||||
hostId: number,
|
||||
): Promise<SSHHost> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/db/host/${hostId}/export`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "export SSH host with credentials");
|
||||
}
|
||||
}
|
||||
|
||||
export function exportAllSSHHosts(): Promise<{
|
||||
hosts: SSHHost[];
|
||||
}>;
|
||||
export function exportAllSSHHosts(options: { share: true }): Promise<{
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
credentials: Record<string, unknown>[];
|
||||
hosts: SSHHost[];
|
||||
}>;
|
||||
export async function exportAllSSHHosts(options?: {
|
||||
share?: boolean;
|
||||
}): Promise<{
|
||||
version?: string;
|
||||
exportedAt?: string;
|
||||
credentials?: Record<string, unknown>[];
|
||||
hosts: SSHHost[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
options?.share ? "/db/hosts/export?share=1" : "/db/hosts/export",
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "export all SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROXY CONNECTIVITY TEST
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
|
||||
export type SyncState = "idle" | "syncing" | "offline" | "error" | "signed_out";
|
||||
|
||||
export interface SyncProxyHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
/** A saved header whose value is not sent back. */
|
||||
set?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncBasicAuth {
|
||||
username: string;
|
||||
password: string;
|
||||
set?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncEntityInfo {
|
||||
type: string;
|
||||
owner: string;
|
||||
ownerName: string | null;
|
||||
readOnly: boolean;
|
||||
onServer: boolean;
|
||||
onDevice: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
linked: boolean;
|
||||
serverUrl?: string;
|
||||
serverName?: string | null;
|
||||
serverVersion?: string | null;
|
||||
account?: {
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
};
|
||||
status?: SyncState;
|
||||
lastError?: string | null;
|
||||
linkedAt?: string;
|
||||
lastSyncAt?: string | null;
|
||||
pending?: number;
|
||||
conflicts?: number;
|
||||
errors?: number;
|
||||
firstSyncDone?: boolean;
|
||||
allowInvalidCertificate?: boolean;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
entities: SyncEntityInfo[];
|
||||
}
|
||||
|
||||
export type ProbeProblem =
|
||||
| "invalid_url"
|
||||
| "unreachable"
|
||||
| "tls_untrusted"
|
||||
| "basic_auth_required"
|
||||
| "proxy_login"
|
||||
| "not_termix"
|
||||
| "version_mismatch"
|
||||
| "different_account";
|
||||
|
||||
export interface ProbeResult {
|
||||
ok: boolean;
|
||||
serverUrl?: string;
|
||||
name?: string;
|
||||
version?: string | null;
|
||||
problem?: ProbeProblem;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ServerTarget {
|
||||
serverUrl: string;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncConflictItem {
|
||||
id: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SyncErrorItem {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export async function getSyncStatus(): Promise<SyncStatus> {
|
||||
return (await authApi.get("/sync/link/status")).data;
|
||||
}
|
||||
|
||||
export async function probeServer(target: ServerTarget): Promise<ProbeResult> {
|
||||
return (await authApi.post("/sync/link/probe", target)).data;
|
||||
}
|
||||
|
||||
export async function getLinkPreview(): Promise<Record<string, number>> {
|
||||
return (await authApi.get("/sync/link/preview")).data?.counts ?? {};
|
||||
}
|
||||
|
||||
export async function completeLink(
|
||||
input: ServerTarget & {
|
||||
token: string;
|
||||
mode: "merge" | "replace";
|
||||
serverName?: string;
|
||||
},
|
||||
): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/complete", input)).data;
|
||||
}
|
||||
|
||||
export async function reloginLink(token: string): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/relogin", { token })).data;
|
||||
}
|
||||
|
||||
export async function unlinkServer(keepData: boolean): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/unlink", { keepData })).data;
|
||||
}
|
||||
|
||||
export async function syncNow(): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/sync", {}, { timeout: 300_000 })).data;
|
||||
}
|
||||
|
||||
export async function updateSyncSettings(settings: {
|
||||
disabledTypes?: string[];
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
}): Promise<SyncStatus> {
|
||||
return (await authApi.put("/sync/link/settings", settings)).data;
|
||||
}
|
||||
|
||||
export async function getSyncConflicts(): Promise<SyncConflictItem[]> {
|
||||
return (await authApi.get("/sync/link/conflicts")).data?.conflicts ?? [];
|
||||
}
|
||||
|
||||
export async function settleSyncConflict(
|
||||
id: number,
|
||||
keep: "mine" | "server",
|
||||
): Promise<void> {
|
||||
await authApi.post(`/sync/link/conflicts/${id}`, { keep });
|
||||
}
|
||||
|
||||
export async function getSyncErrors(): Promise<SyncErrorItem[]> {
|
||||
return (await authApi.get("/sync/link/errors")).data?.errors ?? [];
|
||||
}
|
||||
|
||||
export async function retrySyncErrors(): Promise<SyncStatus> {
|
||||
return (
|
||||
await authApi.post("/sync/link/errors/retry", {}, { timeout: 300_000 })
|
||||
).data;
|
||||
}
|
||||
+42
-28
@@ -42,6 +42,7 @@ import {
|
||||
import {
|
||||
challengeSecondFactor,
|
||||
getLoginMethods,
|
||||
getRememberDeviceAllowed,
|
||||
startLoginRedirect,
|
||||
submitLoginMethod,
|
||||
verifySecondFactor,
|
||||
@@ -137,7 +138,7 @@ function hasDesktopManualLogout() {
|
||||
return localStorage.getItem(DESKTOP_MANUAL_LOGOUT_KEY) === "true";
|
||||
}
|
||||
|
||||
function storeAuth(username: string) {
|
||||
export function storeAuth(username: string) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ loggedIn: true, username }),
|
||||
@@ -232,6 +233,7 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [rememberAllowed, setRememberAllowed] = useState(true);
|
||||
const [rememberMe, setRememberMe] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("rememberMe") === "true";
|
||||
@@ -395,6 +397,12 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
void startPreLoginPlugins()
|
||||
.catch(() => {})
|
||||
.finally(() => setAuthPluginsLoading(false));
|
||||
getRememberDeviceAllowed()
|
||||
.then((allowed) => {
|
||||
setRememberAllowed(allowed);
|
||||
if (!allowed) setRememberMe(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
getLoginMethods()
|
||||
.then((methods) => setAuthMethods(methods))
|
||||
.catch(() => setAuthMethods([]))
|
||||
@@ -1525,19 +1533,21 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
|
||||
{view === "external" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberSSO"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(v) => setRememberMe(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberSSO"
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</label>
|
||||
</div>
|
||||
{rememberAllowed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberSSO"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(v) => setRememberMe(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberSSO"
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{externalMethods.map((method) =>
|
||||
@@ -1573,20 +1583,24 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(v) => setRememberMe(v === true)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</label>
|
||||
</div>
|
||||
{rememberAllowed ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(v) => setRememberMe(v === true)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{passwordResetAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
|
||||
interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: (token: string | null) => void | Promise<void>;
|
||||
onChangeServer: () => void;
|
||||
// "local" (default): the app's own login, the token goes to localStorage
|
||||
// like every other client. "link": signing in to a server to link this
|
||||
// desktop to it; the token is only handed to onAuthSuccess, which trades
|
||||
// it for the desktop's own session.
|
||||
targetPurpose?: "local" | "link";
|
||||
}
|
||||
|
||||
const AUTH_MESSAGE_SOURCES = new Set([
|
||||
"auth_component",
|
||||
"second_factor_auth_component",
|
||||
"method_auth_component",
|
||||
"oidc_callback",
|
||||
]);
|
||||
|
||||
export function ElectronLoginForm({
|
||||
serverUrl,
|
||||
onAuthSuccess,
|
||||
onChangeServer,
|
||||
targetPurpose = "local",
|
||||
}: ElectronLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const isAuthenticatingRef = useRef(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const hasAuthenticatedRef = useRef(false);
|
||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const onAuthSuccessRef = useRef(onAuthSuccess);
|
||||
useEffect(() => {
|
||||
onAuthSuccessRef.current = onAuthSuccess;
|
||||
}, [onAuthSuccess]);
|
||||
|
||||
const handleAuthSuccess = useCallback(
|
||||
async (token: string | null) => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
hasAuthenticatedRef.current = true;
|
||||
isAuthenticatingRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
if (targetPurpose === "link") {
|
||||
if (!token) {
|
||||
throw new Error(t("errors.authTokenMissing"));
|
||||
}
|
||||
} else if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
await onAuthSuccessRef.current(token);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("errors.authTokenSaveFailed"),
|
||||
);
|
||||
isAuthenticatingRef.current = false;
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t, targetPurpose],
|
||||
);
|
||||
|
||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||
// Only the server's own origin is trusted: the iframe can navigate away
|
||||
// (identity provider, redirects), and those pages must not hand us a token,
|
||||
// start a system browser login, or receive the replies.
|
||||
useEffect(() => {
|
||||
let serverOrigin: string;
|
||||
try {
|
||||
serverOrigin = new URL(serverUrl).origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
try {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.origin !== serverOrigin) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const {
|
||||
type,
|
||||
platform,
|
||||
source,
|
||||
token,
|
||||
authUrl,
|
||||
callbackPort,
|
||||
providerId,
|
||||
} = event.data;
|
||||
|
||||
if (
|
||||
type === "AUTH_SUCCESS" &&
|
||||
platform === "desktop" &&
|
||||
AUTH_MESSAGE_SOURCES.has(source)
|
||||
) {
|
||||
await handleAuthSuccess(token ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
// OIDC login requested from inside the iframe, open the system browser
|
||||
// so captcha stages (e.g. Cloudflare Turnstile) render correctly.
|
||||
if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) {
|
||||
const sendResultToIframe = (result: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
secondFactor?: boolean;
|
||||
tempToken?: string;
|
||||
factors?: string;
|
||||
}) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
source: "oidc_system_browser_auth",
|
||||
providerId,
|
||||
...result,
|
||||
},
|
||||
serverOrigin,
|
||||
);
|
||||
};
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
externalBrowserLogin?: (
|
||||
url: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
secondFactor?: boolean;
|
||||
tempToken?: string;
|
||||
factors?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (!electronAPI?.externalBrowserLogin) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: t("errors.failedExternalLogin"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await electronAPI.externalBrowserLogin(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
if (result.success && result.token) {
|
||||
await handleAuthSuccess(result.token);
|
||||
return;
|
||||
}
|
||||
// The login stopped for a second factor; the iframe asks for it.
|
||||
if (result.secondFactor) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
secondFactor: true,
|
||||
tempToken: result.tempToken,
|
||||
factors: result.factors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: result.error || t("errors.failedExternalLogin"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const providerId =
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
typeof event.data.providerId === "number"
|
||||
? event.data.providerId
|
||||
: undefined;
|
||||
const error = getErrorMessage(err, t("errors.failedExternalLogin"));
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
source: "oidc_system_browser_auth",
|
||||
providerId,
|
||||
success: false,
|
||||
error,
|
||||
},
|
||||
serverOrigin,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleAuthSuccess, serverUrl, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoading(false);
|
||||
hasLoadedOnce.current = true;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (iframe.contentWindow) {
|
||||
setCurrentUrl(iframe.contentWindow.location.href);
|
||||
}
|
||||
} catch {
|
||||
setCurrentUrl(serverUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setLoading(false);
|
||||
if (hasLoadedOnce.current) {
|
||||
setError(t("errors.failedToLoadServer"));
|
||||
}
|
||||
};
|
||||
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
iframe.addEventListener("error", handleError);
|
||||
return () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
iframe.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = serverUrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-background flex flex-col">
|
||||
{isAuthenticating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticating && (
|
||||
<div className="flex items-center justify-between p-4 pr-12 bg-background border-b border-border">
|
||||
<button
|
||||
onClick={onChangeServer}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("sync.wizard.back")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isAuthenticating && (
|
||||
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !isAuthenticating && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-background z-40"
|
||||
style={{ marginTop: "60px" }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">
|
||||
{t("auth.loadingServer")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ visibility: isAuthenticating ? "hidden" : "visible" }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={serverUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={t("auth.serverAuthentication")}
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads"
|
||||
allow="clipboard-read; clipboard-write; publickey-credentials-get; publickey-credentials-create"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import type { ThemeId } from "@/types/ui-types";
|
||||
import {
|
||||
SYSTEM_THEME,
|
||||
effectiveThemeId,
|
||||
getTheme,
|
||||
useThemes,
|
||||
} from "@/look/theme-registry";
|
||||
import { applyThemeCss } from "@/look/theme-css";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -10,22 +17,11 @@ type ThemeProviderProps = {
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: ThemeId;
|
||||
setTheme: (theme: ThemeId) => void;
|
||||
/** Pass persist false to show a theme without keeping it in this browser. */
|
||||
setTheme: (theme: ThemeId, persist?: boolean) => void;
|
||||
setThemePreview: (theme: ThemeId | null) => void;
|
||||
};
|
||||
|
||||
const ALL_THEME_CLASSES = [
|
||||
"light",
|
||||
"dark",
|
||||
"dracula",
|
||||
"catppuccin",
|
||||
"nord",
|
||||
"solarized",
|
||||
"tokyo-night",
|
||||
"one-dark",
|
||||
"gruvbox",
|
||||
];
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "dark",
|
||||
setTheme: () => null,
|
||||
@@ -34,6 +30,9 @@ const initialState: ThemeProviderState = {
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
/** The class last put on <html>, in case its theme has since gone away. */
|
||||
let appliedClass: string | null = null;
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "dark",
|
||||
@@ -44,29 +43,33 @@ export function ThemeProvider({
|
||||
() => (localStorage.getItem(storageKey) as ThemeId) || defaultTheme,
|
||||
);
|
||||
const [previewTheme, setPreviewTheme] = useState<ThemeId | null>(null);
|
||||
// Plugin themes arrive after login; the class and CSS follow them.
|
||||
const themes = useThemes();
|
||||
|
||||
const activeTheme = previewTheme ?? theme;
|
||||
// A saved theme whose plugin is gone shows as System.
|
||||
const shownTheme = effectiveThemeId(theme);
|
||||
const activeTheme = previewTheme ?? shownTheme;
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove(...ALL_THEME_CLASSES);
|
||||
root.classList.remove(...themes.map((entry) => entry.id));
|
||||
if (appliedClass) root.classList.remove(appliedClass);
|
||||
|
||||
if (activeTheme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(activeTheme);
|
||||
}, [activeTheme]);
|
||||
const applied =
|
||||
activeTheme === SYSTEM_THEME
|
||||
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: activeTheme;
|
||||
root.classList.add(applied);
|
||||
appliedClass = applied;
|
||||
applyThemeCss(applied, getTheme(applied));
|
||||
}, [activeTheme, themes]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: ThemeId) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
theme: shownTheme,
|
||||
setTheme: (theme: ThemeId, persist = true) => {
|
||||
if (persist) localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
setThemePreview: (preview: ThemeId | null) => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,448 +0,0 @@
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { VersionBadge } from "@/components/version-badge";
|
||||
import { Facts } from "@/components/panel-layout";
|
||||
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import {
|
||||
defaultConnectAction,
|
||||
hostActionsFor,
|
||||
listHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import {
|
||||
getStatusClasses,
|
||||
useStatusColorScheme,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
|
||||
import type { RecentActivityItem } from "@/main-axios";
|
||||
import type { Host, TabType } from "@/types/ui-types";
|
||||
import { quarterBorders } from "./quarter-borders";
|
||||
import { parseServerTime } from "@/lib/server-time";
|
||||
|
||||
export type VersionStatus =
|
||||
"up_to_date" | "requires_update" | "beta" | "unknown";
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
aside,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
/** Sits beside the label, like the release channel. */
|
||||
aside?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
|
||||
{children}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{aside}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsStrip({
|
||||
hosts,
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: VersionStatus;
|
||||
releaseUrl: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const online = hosts.filter((h) => h.status === "online").length;
|
||||
const big = "text-2xl font-bold leading-none tracking-tight";
|
||||
return (
|
||||
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
|
||||
<Stat
|
||||
label={t("dashboard.version")}
|
||||
aside={
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
releaseUrl={releaseUrl}
|
||||
className="shrink-0 px-1 py-px text-[9px]"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className={`${big} truncate`} title={versionText || undefined}>
|
||||
{versionText || "-"}
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t("dashboard.uptime")}>
|
||||
<span className={big}>{uptimeFormatted || "-"}</span>
|
||||
</Stat>
|
||||
<Stat label={t("dashboard.database")}>
|
||||
<span
|
||||
className={`${big} ${dbHealth === "healthy" ? "text-accent-brand" : "text-destructive"}`}
|
||||
>
|
||||
{dbHealth === "healthy"
|
||||
? t("dashboard.healthy")
|
||||
: t("dashboard.error")}
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t("dashboardTab.hostsAvailable")}>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={big}>{online}</span>
|
||||
<span className="text-sm leading-none text-muted-foreground">
|
||||
/{hosts.length}
|
||||
</span>
|
||||
</div>
|
||||
</Stat>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountersStrip({
|
||||
hosts,
|
||||
credentialCount,
|
||||
onOpenSingletonTab,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
credentialCount: number;
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const item =
|
||||
"flex items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50";
|
||||
return (
|
||||
<div className="grid h-full auto-cols-fr grid-flow-col divide-x divide-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSingletonTab("host-manager")}
|
||||
className={item}
|
||||
>
|
||||
<Server className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xl font-bold leading-none tracking-tight">
|
||||
{hosts.length}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{t("dashboard.totalHosts")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpenSingletonTab("host-manager", "host-manager:show-credentials")
|
||||
}
|
||||
className={item}
|
||||
>
|
||||
<KeyRound className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xl font-bold leading-none tracking-tight">
|
||||
{credentialCount}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</span>
|
||||
</button>
|
||||
<ComponentSlot slotId="dashboard.counters" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
hint,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
hint: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`group/btn flex h-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50 ${className ?? ""}`}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center border border-border bg-muted transition-colors group-hover/btn:border-accent-brand/40 group-hover/btn:bg-accent-brand/20">
|
||||
<Icon className="size-3 text-accent-brand" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium">{label}</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickActions({
|
||||
onOpenSingletonTab,
|
||||
hosts,
|
||||
onOpenTab,
|
||||
isAdmin,
|
||||
}: {
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
hosts: Host[];
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pinnedHosts = hosts.filter((h) => h.pin).slice(0, 4);
|
||||
const endpoint = (host: Host) => {
|
||||
const protocol = host.enableSsh ? undefined : enabledHostProtocols(host)[0];
|
||||
const port = host.enableSsh
|
||||
? host.sshPort
|
||||
: protocol
|
||||
? protocolPort(host.pluginSettings, protocol)
|
||||
: host.port;
|
||||
return `${host.ip}:${port ?? host.port}`;
|
||||
};
|
||||
const actions = [
|
||||
{
|
||||
icon: Plus,
|
||||
label: t("dashboard.addHost"),
|
||||
hint: t("dashboardTab.registerNewServer"),
|
||||
onClick: () =>
|
||||
onOpenSingletonTab("host-manager", "host-manager:add-host"),
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
label: t("dashboard.addCredential"),
|
||||
hint: t("dashboardTab.storeSshKeysOrPasswords"),
|
||||
onClick: () =>
|
||||
onOpenSingletonTab("host-manager", "host-manager:add-credential"),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
icon: Settings,
|
||||
label: t("dashboard.adminSettings"),
|
||||
hint: t("dashboardTab.manageUsersAndRoles"),
|
||||
onClick: () => onOpenSingletonTab("admin-settings"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: User,
|
||||
label: t("dashboard.userProfile"),
|
||||
hint: t("dashboardTab.manageYourAccount"),
|
||||
onClick: () => onOpenSingletonTab("user-profile"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col">
|
||||
<div className="grid flex-1 auto-rows-fr grid-cols-1 sm:grid-cols-2">
|
||||
{actions.map((action, i) => (
|
||||
<ActionButton
|
||||
key={action.label}
|
||||
{...action}
|
||||
className={quarterBorders(i, actions.length)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{pinnedHosts.length > 0 && (
|
||||
<div className="flex flex-col border-t border-border">
|
||||
{pinnedHosts.map((host) => {
|
||||
const Icon =
|
||||
defaultConnectAction(listHostActions(), host)?.icon ?? Server;
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const type = getDefaultConnectionTab(host);
|
||||
if (type) onOpenTab(host, type);
|
||||
}}
|
||||
className="flex items-center gap-2 border-b border-border/60 px-3 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
<Icon className="size-3 shrink-0 text-accent-brand" />
|
||||
<span className="truncate text-xs font-medium">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="ml-auto truncate font-mono text-[10px] text-muted-foreground">
|
||||
{endpoint(host)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostStatusList({
|
||||
hosts,
|
||||
onOpenTab,
|
||||
statusLoading,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
statusLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useStatusColorScheme();
|
||||
// A phone has no room for the metric bars next to the host name.
|
||||
const isMobile = useIsMobile();
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
|
||||
{t("dashboardTab.noHostsConfigured")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{hosts.map((host) => {
|
||||
const availability = host.status ?? "offline";
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
// An overview action (a metrics view) wins over connecting.
|
||||
const actions = hostActionsFor(listHostActions(), host);
|
||||
const target =
|
||||
actions.find((action) => action.overview)?.tabType ??
|
||||
defaultConnectAction(actions, host)?.tabType;
|
||||
if (target) onOpenTab(host, target);
|
||||
}}
|
||||
className="group/row flex min-w-0 cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(availability, scheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<Facts className="min-w-0">
|
||||
<span
|
||||
className="truncate text-xs font-medium"
|
||||
title={host.name}
|
||||
>
|
||||
{host.name}
|
||||
</span>
|
||||
<span
|
||||
className="truncate font-mono text-[10px] text-muted-foreground"
|
||||
title={host.ip}
|
||||
>
|
||||
{host.ip}
|
||||
</span>
|
||||
</Facts>
|
||||
</span>
|
||||
{/* Plugins add live details to a host row here. */}
|
||||
{!isMobile && (
|
||||
<ComponentSlot
|
||||
slotId="dashboard.hostRow"
|
||||
props={{
|
||||
hostId: Number(host.id),
|
||||
host,
|
||||
online: availability === "online",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`shrink-0 border px-1.5 py-0.5 text-[10px] font-semibold ${getStatusClasses(availability, scheme, "badge", statusLoading)}`}
|
||||
>
|
||||
{statusLoading || availability === "unknown"
|
||||
? t("dashboardTab.checking")
|
||||
: availability === "online"
|
||||
? t("hosts.status.online")
|
||||
: t("hosts.status.offline")}
|
||||
</span>
|
||||
<ExternalLink className="size-2.5 shrink-0 text-muted-foreground/0 transition-colors group-hover/row:text-muted-foreground/60" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentActivityList({
|
||||
activity,
|
||||
hosts,
|
||||
onOpenTab,
|
||||
statusLoading,
|
||||
}: {
|
||||
activity: RecentActivityItem[];
|
||||
hosts: Host[];
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
statusLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useStatusColorScheme();
|
||||
const ago = (ts: string) => {
|
||||
const diff = Math.floor(
|
||||
(Date.now() - parseServerTime(ts).getTime()) / 1000,
|
||||
);
|
||||
if (diff < 60) return t("dashboard.justNow");
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
};
|
||||
if (activity.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{activity.map((item) => {
|
||||
const host = hosts.find((h) => h.id === String(item.hostId));
|
||||
const target = activityTarget(item.type);
|
||||
const Icon = target?.icon ?? Server;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (host && target) onOpenTab(host, target.tab);
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(host?.status ?? false, scheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<Facts className="min-w-0">
|
||||
<span
|
||||
className="truncate text-xs font-medium"
|
||||
title={item.hostName}
|
||||
>
|
||||
{item.hostName}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Icon className="size-2.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{target?.labelKey
|
||||
? t(target.labelKey)
|
||||
: item.type.replace("_", " ")}
|
||||
</span>
|
||||
</span>
|
||||
</Facts>
|
||||
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{ago(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Lines only between cells, so the actions read as quarters: one column on
|
||||
* small screens, two above. An odd last cell spans the full row.
|
||||
*/
|
||||
export function quarterBorders(index: number, count: number): string {
|
||||
const classes = ["border-border"];
|
||||
const last = index === count - 1;
|
||||
if (!last) classes.push("border-b");
|
||||
if (Math.floor(index / 2) === Math.floor((count - 1) / 2)) {
|
||||
classes.push("sm:border-b-0");
|
||||
}
|
||||
if (index % 2 === 0 && !last) classes.push("sm:border-r");
|
||||
if (last && count % 2 === 1) classes.push("sm:col-span-2");
|
||||
return classes.join(" ");
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { getSyncStatusSnapshot, subscribeSyncStatus } from "@/lib/sync-status";
|
||||
|
||||
/** The desktop's sync status, kept current while the component is mounted. */
|
||||
export function useSyncStatus() {
|
||||
return useSyncExternalStore(
|
||||
subscribeSyncStatus,
|
||||
getSyncStatusSnapshot,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
/** What needs the user's attention in Sync, for the rail badge. */
|
||||
export function useSyncAttentionCount(): number | null {
|
||||
const status = useSyncStatus();
|
||||
if (!status?.linked) return null;
|
||||
const count =
|
||||
(status.conflicts ?? 0) +
|
||||
(status.errors ?? 0) +
|
||||
(status.status === "signed_out" ? 1 : 0);
|
||||
return count || null;
|
||||
}
|
||||
+25
-282
@@ -136,288 +136,6 @@
|
||||
--terminal-bg: #0c0d0b;
|
||||
}
|
||||
|
||||
/* ── Dracula ────────────────────────────────────────────────── */
|
||||
.dracula {
|
||||
--background: #282a36;
|
||||
--foreground: #f8f8f2;
|
||||
--card: #2d303d;
|
||||
--card-foreground: #f8f8f2;
|
||||
--popover: #2d303d;
|
||||
--popover-foreground: #f8f8f2;
|
||||
--primary: #8be9fd;
|
||||
--primary-foreground: #282a36;
|
||||
--secondary: #50536b;
|
||||
--secondary-foreground: #f8f8f2;
|
||||
--surface: #2d303d;
|
||||
--surface-dim: #21222c;
|
||||
--muted: #50536b;
|
||||
--muted-foreground: #a8b3d9;
|
||||
--accent: #5a5e7a;
|
||||
--accent-foreground: #f8f8f2;
|
||||
--destructive: #ff5555;
|
||||
--border: rgba(168, 179, 217, 0.18);
|
||||
--input: rgba(168, 179, 217, 0.14);
|
||||
--ring: #8be9fd;
|
||||
--chart-1: #ff79c6;
|
||||
--chart-2: #8be9fd;
|
||||
--chart-3: #50fa7b;
|
||||
--chart-4: #ffb86c;
|
||||
--chart-5: #bd93f9;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #21222c;
|
||||
--sidebar-foreground: #f8f8f2;
|
||||
--sidebar-primary: #8be9fd;
|
||||
--sidebar-primary-foreground: #282a36;
|
||||
--sidebar-accent: #5a5e7a;
|
||||
--sidebar-accent-foreground: #f8f8f2;
|
||||
--sidebar-border: rgba(168, 179, 217, 0.18);
|
||||
--sidebar-ring: #8be9fd;
|
||||
--warning: #f1fa8c;
|
||||
--terminal-bg: #282a36;
|
||||
}
|
||||
|
||||
/* ── Catppuccin Mocha ───────────────────────────────────────── */
|
||||
.catppuccin {
|
||||
--background: #1e1e2e;
|
||||
--foreground: #cdd6f4;
|
||||
--card: #202235;
|
||||
--card-foreground: #cdd6f4;
|
||||
--popover: #202235;
|
||||
--popover-foreground: #cdd6f4;
|
||||
--primary: #89b4fa;
|
||||
--primary-foreground: #1e1e2e;
|
||||
--secondary: #3b3d52;
|
||||
--secondary-foreground: #cdd6f4;
|
||||
--surface: #202235;
|
||||
--surface-dim: #181825;
|
||||
--muted: #3b3d52;
|
||||
--muted-foreground: #b8c0e0;
|
||||
--accent: #45475a;
|
||||
--accent-foreground: #cdd6f4;
|
||||
--destructive: #f38ba8;
|
||||
--border: rgba(186, 194, 222, 0.18);
|
||||
--input: rgba(186, 194, 222, 0.14);
|
||||
--ring: #89b4fa;
|
||||
--chart-1: #f38ba8;
|
||||
--chart-2: #89dceb;
|
||||
--chart-3: #a6e3a1;
|
||||
--chart-4: #fab387;
|
||||
--chart-5: #cba6f7;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #181825;
|
||||
--sidebar-foreground: #cdd6f4;
|
||||
--sidebar-primary: #89b4fa;
|
||||
--sidebar-primary-foreground: #1e1e2e;
|
||||
--sidebar-accent: #45475a;
|
||||
--sidebar-accent-foreground: #cdd6f4;
|
||||
--sidebar-border: rgba(186, 194, 222, 0.18);
|
||||
--sidebar-ring: #89b4fa;
|
||||
--warning: #f9e2af;
|
||||
--terminal-bg: #1e1e2e;
|
||||
}
|
||||
|
||||
/* ── Nord ───────────────────────────────────────────────────── */
|
||||
.nord {
|
||||
--background: #2e3440;
|
||||
--foreground: #eceff4;
|
||||
--card: #353c4a;
|
||||
--card-foreground: #eceff4;
|
||||
--popover: #353c4a;
|
||||
--popover-foreground: #eceff4;
|
||||
--primary: #88c0d0;
|
||||
--primary-foreground: #2e3440;
|
||||
--secondary: #434c5e;
|
||||
--secondary-foreground: #eceff4;
|
||||
--surface: #353c4a;
|
||||
--surface-dim: #2a303b;
|
||||
--muted: #434c5e;
|
||||
--muted-foreground: #b4c0d4;
|
||||
--accent: #4c566a;
|
||||
--accent-foreground: #eceff4;
|
||||
--destructive: #bf616a;
|
||||
--border: rgba(180, 192, 212, 0.18);
|
||||
--input: rgba(180, 192, 212, 0.14);
|
||||
--ring: #88c0d0;
|
||||
--chart-1: #bf616a;
|
||||
--chart-2: #88c0d0;
|
||||
--chart-3: #a3be8c;
|
||||
--chart-4: #ebcb8b;
|
||||
--chart-5: #b48ead;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #2a303b;
|
||||
--sidebar-foreground: #eceff4;
|
||||
--sidebar-primary: #88c0d0;
|
||||
--sidebar-primary-foreground: #2e3440;
|
||||
--sidebar-accent: #4c566a;
|
||||
--sidebar-accent-foreground: #eceff4;
|
||||
--sidebar-border: rgba(180, 192, 212, 0.18);
|
||||
--sidebar-ring: #88c0d0;
|
||||
--warning: #ebcb8b;
|
||||
--terminal-bg: #2e3440;
|
||||
}
|
||||
|
||||
/* ── Solarized Dark ─────────────────────────────────────────── */
|
||||
.solarized {
|
||||
--background: #002b36;
|
||||
--foreground: #b7c4c4;
|
||||
--card: #073642;
|
||||
--card-foreground: #b7c4c4;
|
||||
--popover: #073642;
|
||||
--popover-foreground: #b7c4c4;
|
||||
--primary: #268bd2;
|
||||
--primary-foreground: #002b36;
|
||||
--secondary: #0b4654;
|
||||
--secondary-foreground: #b7c4c4;
|
||||
--surface: #073642;
|
||||
--surface-dim: #00212b;
|
||||
--muted: #0b4654;
|
||||
--muted-foreground: #9fb3b3;
|
||||
--accent: #105667;
|
||||
--accent-foreground: #d5dddd;
|
||||
--destructive: #dc322f;
|
||||
--border: rgba(159, 179, 179, 0.2);
|
||||
--input: rgba(159, 179, 179, 0.16);
|
||||
--ring: #268bd2;
|
||||
--chart-1: #dc322f;
|
||||
--chart-2: #2aa198;
|
||||
--chart-3: #859900;
|
||||
--chart-4: #b58900;
|
||||
--chart-5: #6c71c4;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #073642;
|
||||
--sidebar-foreground: #b7c4c4;
|
||||
--sidebar-primary: #268bd2;
|
||||
--sidebar-primary-foreground: #002b36;
|
||||
--sidebar-accent: #105667;
|
||||
--sidebar-accent-foreground: #d5dddd;
|
||||
--sidebar-border: rgba(159, 179, 179, 0.2);
|
||||
--sidebar-ring: #268bd2;
|
||||
--warning: #b58900;
|
||||
--terminal-bg: #002b36;
|
||||
}
|
||||
|
||||
/* ── Tokyo Night ────────────────────────────────────────────── */
|
||||
|
||||
.tokyo-night {
|
||||
--background: #1a1b26;
|
||||
--foreground: #c0caf5;
|
||||
--card: #1f2335;
|
||||
--card-foreground: #c0caf5;
|
||||
--popover: #1f2335;
|
||||
--popover-foreground: #c0caf5;
|
||||
--primary: #7aa2f7;
|
||||
--primary-foreground: #1a1b26;
|
||||
--secondary: #2a2f47;
|
||||
--secondary-foreground: #c0caf5;
|
||||
--surface: #1f2335;
|
||||
--surface-dim: #16161e;
|
||||
--muted: #2a2f47;
|
||||
--muted-foreground: #8b93bd;
|
||||
--accent: #2f3654;
|
||||
--accent-foreground: #c0caf5;
|
||||
--destructive: #f7768e;
|
||||
--border: rgba(139, 147, 189, 0.2);
|
||||
--input: rgba(139, 147, 189, 0.16);
|
||||
--ring: #7aa2f7;
|
||||
--chart-1: #f7768e;
|
||||
--chart-2: #7dcfff;
|
||||
--chart-3: #9ece6a;
|
||||
--chart-4: #e0af68;
|
||||
--chart-5: #bb9af7;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #16161e;
|
||||
--sidebar-foreground: #c0caf5;
|
||||
--sidebar-primary: #7aa2f7;
|
||||
--sidebar-primary-foreground: #1a1b26;
|
||||
--sidebar-accent: #2f3654;
|
||||
--sidebar-accent-foreground: #c0caf5;
|
||||
--sidebar-border: rgba(139, 147, 189, 0.2);
|
||||
--sidebar-ring: #7aa2f7;
|
||||
--warning: #e0af68;
|
||||
--terminal-bg: #1a1b26;
|
||||
}
|
||||
|
||||
/* ── One Dark ───────────────────────────────────────────────── */
|
||||
.one-dark {
|
||||
--background: #282c34;
|
||||
--foreground: #abb2bf;
|
||||
--card: #2f343d;
|
||||
--card-foreground: #abb2bf;
|
||||
--popover: #2f343d;
|
||||
--popover-foreground: #abb2bf;
|
||||
--primary: #61afef;
|
||||
--primary-foreground: #282c34;
|
||||
--secondary: #3a404b;
|
||||
--secondary-foreground: #abb2bf;
|
||||
--surface: #2f343d;
|
||||
--surface-dim: #21252b;
|
||||
--muted: #3a404b;
|
||||
--muted-foreground: #9aa3b2;
|
||||
--accent: #424957;
|
||||
--accent-foreground: #abb2bf;
|
||||
--destructive: #e06c75;
|
||||
--border: rgba(154, 163, 178, 0.18);
|
||||
--input: rgba(154, 163, 178, 0.14);
|
||||
--ring: #61afef;
|
||||
--chart-1: #e06c75;
|
||||
--chart-2: #56b6c2;
|
||||
--chart-3: #98c379;
|
||||
--chart-4: #e5c07b;
|
||||
--chart-5: #c678dd;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #21252b;
|
||||
--sidebar-foreground: #abb2bf;
|
||||
--sidebar-primary: #61afef;
|
||||
--sidebar-primary-foreground: #282c34;
|
||||
--sidebar-accent: #424957;
|
||||
--sidebar-accent-foreground: #abb2bf;
|
||||
--sidebar-border: rgba(154, 163, 178, 0.18);
|
||||
--sidebar-ring: #61afef;
|
||||
--warning: #e5c07b;
|
||||
--terminal-bg: #282c34;
|
||||
}
|
||||
|
||||
/* ── Gruvbox Dark ───────────────────────────────────────────── */
|
||||
|
||||
.gruvbox {
|
||||
--background: #282828;
|
||||
--foreground: #ebdbb2;
|
||||
--card: #32302f;
|
||||
--card-foreground: #ebdbb2;
|
||||
--popover: #32302f;
|
||||
--popover-foreground: #ebdbb2;
|
||||
--primary: #fabd2f;
|
||||
--primary-foreground: #282828;
|
||||
--secondary: #45403d;
|
||||
--secondary-foreground: #ebdbb2;
|
||||
--surface: #32302f;
|
||||
--surface-dim: #1d2021;
|
||||
--muted: #45403d;
|
||||
--muted-foreground: #c4b59d;
|
||||
--accent: #504945;
|
||||
--accent-foreground: #ebdbb2;
|
||||
--destructive: #fb4934;
|
||||
--border: rgba(196, 181, 157, 0.18);
|
||||
--input: rgba(196, 181, 157, 0.14);
|
||||
--ring: #fabd2f;
|
||||
--chart-1: #fb4934;
|
||||
--chart-2: #8ec07c;
|
||||
--chart-3: #b8bb26;
|
||||
--chart-4: #fabd2f;
|
||||
--chart-5: #d3869b;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #1d2021;
|
||||
--sidebar-foreground: #ebdbb2;
|
||||
--sidebar-primary: #fabd2f;
|
||||
--sidebar-primary-foreground: #282828;
|
||||
--sidebar-accent: #504945;
|
||||
--sidebar-accent-foreground: #ebdbb2;
|
||||
--sidebar-border: rgba(196, 181, 157, 0.18);
|
||||
--sidebar-ring: #fabd2f;
|
||||
--warning: #fabd2f;
|
||||
--terminal-bg: #282828;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
/* Tailwind v4 defaults border-color to currentColor, so any bare "border"
|
||||
@@ -837,3 +555,28 @@ html.fs-xl {
|
||||
background-color: color-mix(in oklab, var(--accent-brand) 35%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* The title bar the desktop app draws on top of its window. The window has
|
||||
* no native frame, so this strip is what moves the window around: the whole
|
||||
* bar drags, everything inside it that takes clicks does not.
|
||||
*/
|
||||
.termix-titlebar {
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.termix-titlebar button,
|
||||
.termix-titlebar a,
|
||||
.termix-titlebar input,
|
||||
.termix-titlebar [role="button"] {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/*
|
||||
* Layouts size themselves to the viewport with h-dvh. Under the app-drawn
|
||||
* title bar the usable height is shorter, so trim it while the bar shows.
|
||||
*/
|
||||
.h-dvh {
|
||||
height: calc(100dvh - var(--termix-titlebar-height, 0px));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** The version of this build, empty in a dev build. */
|
||||
export const APP_VERSION: string | undefined =
|
||||
import.meta.env.VITE_APP_VERSION || undefined;
|
||||
@@ -16,14 +16,20 @@ export function isInMobileWebView(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// The desktop app's own user agent. The link window frames the server
|
||||
// cross-origin, and Electron does not carry webContents.setUserAgent into
|
||||
// such a frame, so there navigator.userAgent is Electron's default, which
|
||||
// still names the app: "... termix/26.10.2 Chrome/... Electron/43.7.7 ...".
|
||||
const DESKTOP_USER_AGENT = /Termix-Desktop\/|\btermix\/[\w.-]+ .*\bElectron\//i;
|
||||
|
||||
// Being framed alone is not enough: any site could frame this page and
|
||||
// receive the token posted to window.parent. The desktop app tags every
|
||||
// request and frame with its own user agent, which a web page cannot fake
|
||||
// in someone else's browser.
|
||||
// receive the token posted to window.parent. The desktop app's user agent,
|
||||
// in either form above, is something a web page cannot fake in someone
|
||||
// else's browser.
|
||||
export function isInElectronWebView(): boolean {
|
||||
if (isInMobileWebView()) return false;
|
||||
if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true;
|
||||
if (!/Termix-Desktop\//.test(navigator.userAgent)) return false;
|
||||
if (!DESKTOP_USER_AGENT.test(navigator.userAgent)) return false;
|
||||
try {
|
||||
if (window.self !== window.top) return true;
|
||||
} catch {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { authApi } from "@/main-axios";
|
||||
import { SYNC_CHANGED_EVENT } from "@/lib/sync-events";
|
||||
@@ -48,3 +49,22 @@ export function notifySyncChanged(): void {
|
||||
cached = null;
|
||||
window.dispatchEvent(new CustomEvent(SYNC_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
/** Whether this desktop is linked to a server, kept current as it changes. */
|
||||
export function useDesktopLinked(): boolean {
|
||||
const [linked, setLinked] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const read = () =>
|
||||
void getLinkedSession().then((session) => {
|
||||
if (live) setLinked(!!session);
|
||||
});
|
||||
read();
|
||||
window.addEventListener(SYNC_CHANGED_EVENT, read);
|
||||
return () => {
|
||||
live = false;
|
||||
window.removeEventListener(SYNC_CHANGED_EVENT, read);
|
||||
};
|
||||
}, []);
|
||||
return linked;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { TFunction } from "i18next";
|
||||
/** The part of a translate function these helpers use. */
|
||||
export type Translator = (
|
||||
key: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
@@ -28,7 +32,7 @@ export function pluginKey(
|
||||
* listed in a registry, shows the manifest name.
|
||||
*/
|
||||
export function pluginName(
|
||||
t: TFunction,
|
||||
t: Translator,
|
||||
plugin: { id: string; name: string },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
|
||||
@@ -36,7 +40,7 @@ export function pluginName(
|
||||
|
||||
/** Same as pluginName, for "plugin.description". */
|
||||
export function pluginDescription(
|
||||
t: TFunction,
|
||||
t: Translator,
|
||||
plugin: { id: string; description?: string | null },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.description"), {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Translator } from "./plugin-i18n";
|
||||
|
||||
/** System roles store an i18n key as their display name. */
|
||||
export function roleLabel<T extends string | null | undefined>(
|
||||
t: TFunction,
|
||||
t: Translator,
|
||||
displayName: T,
|
||||
): T | string {
|
||||
return typeof displayName === "string" &&
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getSyncStatus, type SyncStatus } from "@/api/sync-api";
|
||||
import { notifySyncChanged } from "@/lib/linked-server";
|
||||
|
||||
/**
|
||||
* The desktop's sync status, polled from the embedded backend while anything
|
||||
* shows it. When a pass finishes, the views that show synced data are told
|
||||
* to reload.
|
||||
*/
|
||||
|
||||
const IDLE_MS = 15_000;
|
||||
const BUSY_MS = 2_000;
|
||||
|
||||
let snapshot: SyncStatus | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastSyncAt: string | null | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function announce(): void {
|
||||
notifySyncChanged();
|
||||
window.dispatchEvent(new CustomEvent("hosts:refresh"));
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:plugins-changed"));
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
timer = null;
|
||||
try {
|
||||
const next = await getSyncStatus();
|
||||
const syncedAt = next.lastSyncAt ?? null;
|
||||
if (lastSyncAt !== undefined && syncedAt !== lastSyncAt) announce();
|
||||
if (snapshot && snapshot.linked !== next.linked) notifySyncChanged();
|
||||
lastSyncAt = syncedAt;
|
||||
snapshot = next;
|
||||
emit();
|
||||
} catch {
|
||||
// The backend may still be starting; the next poll tries again.
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule(delay?: number): void {
|
||||
if (listeners.size === 0 || timer) return;
|
||||
const busy = snapshot?.status === "syncing" || !!snapshot?.pending;
|
||||
timer = setTimeout(() => void poll(), delay ?? (busy ? BUSY_MS : IDLE_MS));
|
||||
}
|
||||
|
||||
export function subscribeSyncStatus(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (isElectron() && listeners.size === 1 && !timer) void poll();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getSyncStatusSnapshot(): SyncStatus | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Takes a status a request already returned, and checks again soon. */
|
||||
export function setSyncStatus(next: SyncStatus): void {
|
||||
snapshot = next;
|
||||
if (lastSyncAt !== undefined && (next.lastSyncAt ?? null) !== lastSyncAt) {
|
||||
announce();
|
||||
}
|
||||
lastSyncAt = next.lastSyncAt ?? null;
|
||||
emit();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
schedule(BUSY_MS);
|
||||
}
|
||||
|
||||
export function refreshSyncStatus(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
void poll();
|
||||
}
|
||||
+149
-971
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+153
-1019
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import {
|
||||
chooseDefaultLook,
|
||||
chooseLayout,
|
||||
useLookState,
|
||||
} from "@/look/look-store";
|
||||
import { layoutLabel, useLayouts } from "@/look/layout-registry";
|
||||
import { themeLabel, useThemes } from "@/look/theme-registry";
|
||||
|
||||
const selectClass =
|
||||
"w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7";
|
||||
|
||||
/**
|
||||
* The layout picker, plus the default layout and theme for everyone when an
|
||||
* admin is looking. Lists what the layout and theme registries hold, so
|
||||
* plugin layouts and themes show up as soon as they are installed.
|
||||
*/
|
||||
export function LookSettings({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const look = useLookState();
|
||||
const layouts = useLayouts();
|
||||
const themes = useThemes();
|
||||
const response = look.response;
|
||||
|
||||
const failed = () => toast.error(t("looks.saveFailed"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select2
|
||||
value={response?.layoutId ?? ""}
|
||||
disabled={!response}
|
||||
onChange={(e) =>
|
||||
void chooseLayout(e.target.value || null).catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{layouts.map((layout) => (
|
||||
<option key={layout.id} value={layout.id}>
|
||||
{layoutLabel(layout, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("looks.layoutHint")}
|
||||
</p>
|
||||
|
||||
{isAdmin && response && (
|
||||
<div className="flex flex-col gap-1.5 border border-border p-2">
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
{t("looks.defaultsLabel")}
|
||||
</span>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("looks.defaultsHint")}
|
||||
</p>
|
||||
<Select2
|
||||
value={response.defaults.layout ?? ""}
|
||||
onChange={(e) =>
|
||||
void chooseDefaultLook({ layout: e.target.value || null })
|
||||
.then(() => toast.success(t("looks.defaultSaved")))
|
||||
.catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{layouts.map((layout) => (
|
||||
<option key={layout.id} value={layout.id}>
|
||||
{layoutLabel(layout, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<Select2
|
||||
value={response.defaults.theme ?? ""}
|
||||
onChange={(e) =>
|
||||
void chooseDefaultLook({ theme: e.target.value || null })
|
||||
.then(() => toast.success(t("looks.defaultSaved")))
|
||||
.catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{themes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{themeLabel(theme, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
LayoutTemplate,
|
||||
Palette,
|
||||
History,
|
||||
Power,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { ListRow } from "@/components/list-kit";
|
||||
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
|
||||
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
||||
import { PluginIconBox } from "@/plugins/plugin-bits";
|
||||
import { getPluginRecord } from "@/plugin-host/plugin-store";
|
||||
import { restoreBundledPlugin } from "@/api/plugins-api";
|
||||
import { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider";
|
||||
import {
|
||||
chooseDefaultLook,
|
||||
chooseLayout,
|
||||
chooseThemeForAccount,
|
||||
} from "./look-store";
|
||||
import { layoutLabel, useLayouts } from "./layout-registry";
|
||||
import { themeLabel, useThemes } from "./theme-registry";
|
||||
import { useChooseTheme } from "./use-look-theme";
|
||||
import type { LayoutStatus } from "./layout-status";
|
||||
|
||||
type RepairStatus = Extract<LayoutStatus, { kind: "repair" }>;
|
||||
|
||||
const LOOK_CAPABILITIES = ["ui:layout", "ui:theme"];
|
||||
|
||||
/**
|
||||
* What an admin can do about a broken layout: update or disable its plugin,
|
||||
* and install another layout or theme. Uses the Plugins tab's own flows.
|
||||
*/
|
||||
function AdminTools({
|
||||
status,
|
||||
installOpen,
|
||||
setInstallOpen,
|
||||
onRetry,
|
||||
}: {
|
||||
status: RepairStatus;
|
||||
installOpen: boolean;
|
||||
setInstallOpen: (open: boolean) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const manager = usePluginsManager();
|
||||
const entry = status.pluginId
|
||||
? manager.plugins.find((plugin) => plugin.id === status.pluginId)
|
||||
: undefined;
|
||||
const installable = manager.plugins.filter(
|
||||
(plugin) =>
|
||||
!plugin.installed &&
|
||||
plugin.versions[0]?.capabilities?.some((capability) =>
|
||||
LOOK_CAPABILITIES.includes(capability),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{entry?.installed && entry.updateAvailable && entry.latestVersion && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void manager.requestUpdate(entry)}
|
||||
disabled={manager.busy.has(entry.id)}
|
||||
>
|
||||
<Download />
|
||||
{t("looks.repair.update", {
|
||||
name: entry.name,
|
||||
version: entry.latestVersion,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{entry?.installed && entry.enabled && !entry.managedByServer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void manager.toggle(entry)}
|
||||
disabled={manager.busy.has(entry.id)}
|
||||
>
|
||||
<Power />
|
||||
{t("looks.repair.disable", { name: entry.name })}
|
||||
</Button>
|
||||
)}
|
||||
{entry?.installed && entry.bundled && !entry.managedByServer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void restoreBundledPlugin(entry.id)
|
||||
.then(onRetry)
|
||||
.catch(() => toast.error(t("looks.repair.restoreFailed")));
|
||||
}}
|
||||
disabled={manager.busy.has(entry.id)}
|
||||
>
|
||||
<History />
|
||||
{t("looks.repair.restore", { name: entry.name })}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setInstallOpen(true)}>
|
||||
<Download />
|
||||
{t("looks.repair.install")}
|
||||
</Button>
|
||||
|
||||
<InlineView
|
||||
open={installOpen}
|
||||
onOpenChange={setInstallOpen}
|
||||
title={t("looks.repair.installTitle")}
|
||||
icon={<LayoutTemplate className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{manager.loading && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
)}
|
||||
{!manager.loading && installable.length === 0 && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{manager.registryError
|
||||
? t("looks.repair.registryUnreachable")
|
||||
: t("looks.repair.nothingToInstall")}
|
||||
</p>
|
||||
)}
|
||||
{installable.map((plugin, index) => (
|
||||
<ListRow
|
||||
key={`${plugin.registryId ?? ""}:${plugin.id}`}
|
||||
stripe={index}
|
||||
icon={<PluginIconBox name={plugin.icon} size="sm" />}
|
||||
title={plugin.name}
|
||||
meta={plugin.description}
|
||||
trailing={
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={manager.busy.has(plugin.id)}
|
||||
onClick={() => manager.requestInstall(plugin)}
|
||||
>
|
||||
{t("looks.repair.installButton")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</InlineView>
|
||||
|
||||
<PluginConsentPrompt
|
||||
request={manager.consent}
|
||||
onCancel={() => manager.setConsent(null)}
|
||||
onConfirm={() => void manager.confirmConsent()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown instead of a layout that cannot be drawn. Small on purpose: it has
|
||||
* to work when everything else is broken, and gets people back to a working
|
||||
* look in one click.
|
||||
*/
|
||||
export function RepairView({
|
||||
status,
|
||||
onRetry,
|
||||
}: {
|
||||
status: RepairStatus;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = usePermissions();
|
||||
const { theme } = useTheme();
|
||||
const chooseTheme = useChooseTheme();
|
||||
const layouts = useLayouts();
|
||||
const themes = useThemes();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
|
||||
const record = status.pluginId ? getPluginRecord(status.pluginId) : null;
|
||||
const layoutName =
|
||||
layouts.find((layout) => layout.id === status.layoutId) ?? null;
|
||||
const name = layoutName
|
||||
? layoutLabel(layoutName, t)
|
||||
: status.layoutId || t("looks.repair.noLayout");
|
||||
const details = [
|
||||
`${t("looks.repair.layout")}: ${name} (${status.layoutId})`,
|
||||
status.pluginId
|
||||
? `${t("looks.repair.plugin")}: ${record?.summary.name ?? status.pluginId} ${status.version ?? ""}`.trim()
|
||||
: null,
|
||||
`${t("looks.repair.reason")}: ${t(`looks.repair.reasons.${status.reason}`)}`,
|
||||
status.error ? `${t("looks.repair.error")}: ${status.error}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(details);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t("looks.repair.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const run = (action: () => Promise<void>) => () =>
|
||||
void action().catch(() => toast.error(t("looks.repair.saveFailed")));
|
||||
|
||||
const resetOwn = run(async () => {
|
||||
try {
|
||||
localStorage.removeItem("vite-ui-theme");
|
||||
} catch {
|
||||
// Private mode.
|
||||
}
|
||||
await chooseThemeForAccount(null);
|
||||
await chooseLayout(null);
|
||||
});
|
||||
|
||||
const resetForEveryone = run(() =>
|
||||
chooseDefaultLook({ layout: null, theme: null }),
|
||||
);
|
||||
|
||||
const safe = status.reason === "safe-url" || status.reason === "safe-env";
|
||||
|
||||
return (
|
||||
<SurfaceScope kind="tab" className="h-dvh w-screen bg-background">
|
||||
<ShellConfirmCapture />
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-4 py-10">
|
||||
<div className="flex w-full max-w-xl flex-col gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<TriangleAlert className="size-4 text-warning" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-sm font-semibold text-foreground">
|
||||
{safe ? t("looks.repair.safeTitle") : t("looks.repair.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`looks.repair.reasons.${status.reason}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border border-border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/30 px-3 py-2">
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{name}
|
||||
{status.pluginId && (
|
||||
<span className="text-muted-foreground">
|
||||
{" | "}
|
||||
{record?.summary.name ?? status.pluginId}
|
||||
{status.version ? ` ${status.version}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button size="xs" variant="ghost" onClick={() => void copy()}>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
{copied ? t("looks.repair.copied") : t("looks.repair.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
{status.error ?? t("looks.repair.noError")}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!safe && (
|
||||
<Button size="sm" onClick={onRetry}>
|
||||
<RefreshCw />
|
||||
{t("looks.repair.retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={resetOwn}>
|
||||
<RotateCcw />
|
||||
{t("looks.repair.reset")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button size="sm" variant="outline" onClick={resetForEveryone}>
|
||||
<RotateCcw />
|
||||
{t("looks.repair.resetEveryone")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<AdminTools
|
||||
status={status}
|
||||
installOpen={installOpen}
|
||||
setInstallOpen={setInstallOpen}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<p className="border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("looks.repair.askAdmin")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<LayoutTemplate className="size-3" />
|
||||
{t("looks.repair.switchLayout")}
|
||||
</h2>
|
||||
<div className="flex flex-col border border-border">
|
||||
{layouts.map((layout, index) => (
|
||||
<ListRow
|
||||
key={layout.id}
|
||||
stripe={index}
|
||||
title={layoutLabel(layout, t)}
|
||||
meta={layout.id}
|
||||
selected={layout.id === status.layoutId}
|
||||
onClick={run(() => chooseLayout(layout.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<Palette className="size-3" />
|
||||
{t("looks.repair.switchTheme")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{themes.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
title={themeLabel(entry, t)}
|
||||
aria-pressed={theme === entry.id}
|
||||
onClick={() => chooseTheme(entry.id)}
|
||||
className={`flex items-center gap-1.5 border px-2 py-1 text-[11px] ${theme === entry.id ? "border-accent-brand text-foreground" : "border-border text-muted-foreground hover:border-foreground/40"}`}
|
||||
>
|
||||
<span
|
||||
className="size-3 border border-border/60"
|
||||
style={{ background: entry.swatch.background }}
|
||||
/>
|
||||
{themeLabel(entry, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceScope>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useSyncExternalStore, type ComponentType } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
/** A layout someone can choose, known from a manifest before its code loads. */
|
||||
export interface LayoutDescriptor {
|
||||
id: string;
|
||||
/** i18n key in the plugin namespace, or plain text. */
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultTheme?: string;
|
||||
/** Set for a plugin layout. */
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
/** A layout whose code is running, from app.registerLayout. */
|
||||
export interface RegisteredLayout {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
component: ComponentType;
|
||||
/** Shown on phones and narrow windows. Never in the desktop app. */
|
||||
mobileComponent?: ComponentType;
|
||||
defaultTheme?: string;
|
||||
}
|
||||
|
||||
let descriptorSnapshot: LayoutDescriptor[] = [];
|
||||
const descriptorListeners = new Set<() => void>();
|
||||
|
||||
const registered = createRegistry<RegisteredLayout>();
|
||||
|
||||
export const registerLayout = registered.register;
|
||||
export const getRegisteredLayout = registered.get;
|
||||
export const useRegisteredLayouts = registered.useList;
|
||||
export const resetRegisteredLayouts = registered.reset;
|
||||
|
||||
/** Every layout that can be chosen, from the installed plugins. */
|
||||
export function listLayouts(): LayoutDescriptor[] {
|
||||
return descriptorSnapshot;
|
||||
}
|
||||
|
||||
export function getLayoutDescriptor(id: string): LayoutDescriptor | undefined {
|
||||
return descriptorSnapshot.find((layout) => layout.id === id);
|
||||
}
|
||||
|
||||
/** Replaces the plugin layouts at once, from the plugin list. */
|
||||
export function setPluginLayouts(layouts: LayoutDescriptor[]): void {
|
||||
const key = (list: LayoutDescriptor[]) =>
|
||||
JSON.stringify(
|
||||
list.map((l) => [l.id, l.pluginId, l.label, l.defaultTheme]),
|
||||
);
|
||||
if (key(layouts) === key(descriptorSnapshot)) return;
|
||||
descriptorSnapshot = layouts;
|
||||
for (const listener of descriptorListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeDescriptors(listener: () => void): () => void {
|
||||
descriptorListeners.add(listener);
|
||||
return () => {
|
||||
descriptorListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useLayouts(): LayoutDescriptor[] {
|
||||
return useSyncExternalStore(subscribeDescriptors, listLayouts, listLayouts);
|
||||
}
|
||||
|
||||
export function layoutLabel(layout: LayoutDescriptor, t: TFunction): string {
|
||||
if (!layout.pluginId) return layout.label;
|
||||
return t(`${layout.pluginId}:${layout.label}`, {
|
||||
defaultValue: layout.label,
|
||||
});
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPluginLayouts(): void {
|
||||
setPluginLayouts([]);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { UnavailableLook } from "@/api/look-api";
|
||||
import type { PluginRecord } from "@/plugin-host/plugin-store";
|
||||
import type { RegisteredLayout } from "./layout-registry";
|
||||
|
||||
/** Why the repair view shows instead of the chosen layout. */
|
||||
export type RepairReason =
|
||||
/** ?safe-layout=1 in the address. */
|
||||
| "safe-url"
|
||||
/** TERMIX_SAFE_LAYOUT on the server. */
|
||||
| "safe-env"
|
||||
/** No installed plugin provides the layout. */
|
||||
| "missing"
|
||||
/** Its plugin is switched off. */
|
||||
| "disabled"
|
||||
/** Its plugin waits on a missing dependency or was refused. */
|
||||
| "blocked"
|
||||
/** Its plugin does not fit this Termix or plugin API. */
|
||||
| "incompatible"
|
||||
/** Its code threw while starting. */
|
||||
| "failed"
|
||||
/** It crashed while drawing, twice. */
|
||||
| "crashed";
|
||||
|
||||
export type LayoutStatus =
|
||||
| { kind: "ok"; layout: RegisteredLayout }
|
||||
| { kind: "loading" }
|
||||
| {
|
||||
kind: "repair";
|
||||
reason: RepairReason;
|
||||
layoutId: string;
|
||||
pluginId?: string;
|
||||
version?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
/** A crash this many times in a row hands over to the repair view. */
|
||||
export const MAX_LAYOUT_CRASHES = 2;
|
||||
|
||||
export interface LayoutStatusInput {
|
||||
layoutId: string;
|
||||
safeFromUrl: boolean;
|
||||
safeFromEnv: boolean;
|
||||
/** The look and the plugin list have both been read. */
|
||||
ready: boolean;
|
||||
registered: RegisteredLayout | undefined;
|
||||
/** The installed plugin whose manifest declares the layout, if any. */
|
||||
owner: PluginRecord | undefined;
|
||||
/** A plugin the server skipped that declares the layout, if any. */
|
||||
unavailable: UnavailableLook | undefined;
|
||||
crashes: number;
|
||||
lastCrash: string | null;
|
||||
}
|
||||
|
||||
/** Whether the chosen layout can be drawn, and why not when it cannot. */
|
||||
export function layoutStatus(input: LayoutStatusInput): LayoutStatus {
|
||||
const repair = (
|
||||
reason: RepairReason,
|
||||
extra: { error?: string | null } = {},
|
||||
): LayoutStatus => ({
|
||||
kind: "repair",
|
||||
reason,
|
||||
layoutId: input.layoutId,
|
||||
pluginId: input.owner?.summary.id ?? input.unavailable?.id,
|
||||
version: input.owner?.summary.version ?? input.unavailable?.version ?? null,
|
||||
error: extra.error ?? null,
|
||||
});
|
||||
|
||||
if (input.safeFromUrl) return repair("safe-url");
|
||||
if (input.safeFromEnv) return repair("safe-env");
|
||||
if (input.crashes >= MAX_LAYOUT_CRASHES) {
|
||||
return repair("crashed", { error: input.lastCrash });
|
||||
}
|
||||
if (input.registered) return { kind: "ok", layout: input.registered };
|
||||
if (!input.ready) return { kind: "loading" };
|
||||
|
||||
if (input.unavailable && !input.owner) {
|
||||
return repair("incompatible", { error: input.unavailable.error });
|
||||
}
|
||||
const owner = input.owner;
|
||||
if (!owner) return repair("missing");
|
||||
const summary = owner.summary;
|
||||
if (!summary.enabled) return repair("disabled");
|
||||
if (owner.frontend === "blocked" || summary.state === "blocked") {
|
||||
return repair("blocked", { error: owner.error ?? summary.lastError });
|
||||
}
|
||||
if (owner.frontend === "failed" || summary.state === "failed") {
|
||||
return repair("failed", { error: owner.error ?? summary.lastError });
|
||||
}
|
||||
if (owner.frontend === "loading") return { kind: "loading" };
|
||||
// Just chosen: the runtime is about to load its code.
|
||||
if (
|
||||
summary.frontend &&
|
||||
(owner.frontend === "inactive" || owner.frontend === "none")
|
||||
) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
// Running, but it never registered the layout it declares.
|
||||
return repair("missing");
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getLook,
|
||||
saveDefaultLook,
|
||||
saveUserLook,
|
||||
type LookResponse,
|
||||
} from "@/api/look-api";
|
||||
import { resolveLayoutId, safeLayoutInUrl } from "./resolve-look";
|
||||
|
||||
const LAST_LAYOUT_KEY = "termix-layout";
|
||||
|
||||
interface LookState {
|
||||
/** The server answered, or failed to; the look is decided either way. */
|
||||
loaded: boolean;
|
||||
response: LookResponse | null;
|
||||
}
|
||||
|
||||
let state: LookState = { loaded: false, response: null };
|
||||
let request: Promise<LookResponse | null> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function setState(next: LookState) {
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function readLastLayout(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LAST_LAYOUT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the look once per session. Never rejects: no answer means defaults. */
|
||||
export function loadLook(): Promise<LookResponse | null> {
|
||||
request ??= getLook()
|
||||
.then((response) => {
|
||||
setState({ loaded: true, response });
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
setState({ loaded: true, response: null });
|
||||
return null;
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Fetches again, e.g. after an admin changed the defaults. */
|
||||
export function reloadLook(): Promise<LookResponse | null> {
|
||||
request = null;
|
||||
return loadLook();
|
||||
}
|
||||
|
||||
/** On logout: the next user has their own look. */
|
||||
export function resetLook(): void {
|
||||
request = null;
|
||||
setState({ loaded: false, response: null });
|
||||
}
|
||||
|
||||
export function getLookState(): LookState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeLook(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useLookState(): LookState {
|
||||
return useSyncExternalStore(subscribeLook, getLookState, getLookState);
|
||||
}
|
||||
|
||||
/** The layout to show now. Remembered so a failed fetch next time still knows. */
|
||||
export function chosenLayoutId(): string {
|
||||
const id = resolveLayoutId(state.response, readLastLayout());
|
||||
if (state.response) {
|
||||
try {
|
||||
localStorage.setItem(LAST_LAYOUT_KEY, id);
|
||||
} catch {
|
||||
// Private mode; the server is asked every time anyway.
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The URL or the server asks for the repair view. */
|
||||
export function safeLayoutRequested(): boolean {
|
||||
return (
|
||||
(typeof window !== "undefined" &&
|
||||
safeLayoutInUrl(window.location.search)) ||
|
||||
state.response?.safeLayout === true
|
||||
);
|
||||
}
|
||||
|
||||
function patchResponse(patch: Partial<LookResponse>) {
|
||||
if (!state.response) return;
|
||||
setState({ ...state, response: { ...state.response, ...patch } });
|
||||
}
|
||||
|
||||
/** Tells the plugin runtime to load the chosen layout's code. */
|
||||
function announceLayoutChange() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("termix:plugins-changed"));
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's own layout; null goes back to the default. */
|
||||
export async function chooseLayout(layoutId: string | null): Promise<void> {
|
||||
await saveUserLook({ layoutId });
|
||||
patchResponse({ layoutId });
|
||||
announceLayoutChange();
|
||||
}
|
||||
|
||||
/** The user's own theme; null goes back to the default. */
|
||||
export async function chooseThemeForAccount(
|
||||
themeId: string | null,
|
||||
): Promise<void> {
|
||||
await saveUserLook({ themeId });
|
||||
patchResponse({ themeId });
|
||||
}
|
||||
|
||||
/** Admin only: what everyone sees until they choose their own. */
|
||||
export async function chooseDefaultLook(defaults: {
|
||||
layout?: string | null;
|
||||
theme?: string | null;
|
||||
}): Promise<void> {
|
||||
const saved = await saveDefaultLook(defaults);
|
||||
patchResponse({ defaults: saved });
|
||||
announceLayoutChange();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LookResponse } from "@/api/look-api";
|
||||
|
||||
/** The theme a fresh install starts in when nothing else says otherwise. */
|
||||
export const FALLBACK_THEME = "dark";
|
||||
|
||||
/**
|
||||
* The layout to show: the user's choice, else the admin default, else the
|
||||
* default look. Empty when none of them names one, which the repair view
|
||||
* answers. Without an answer from the server the last layout this browser
|
||||
* showed stands in.
|
||||
*/
|
||||
export function resolveLayoutId(
|
||||
look: LookResponse | null,
|
||||
lastShown: string | null = null,
|
||||
): string {
|
||||
if (!look) return lastShown ?? "";
|
||||
return look.layoutId ?? look.defaults.layout ?? look.defaultLook.layout ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme to show: the user's choice, else the one this browser kept
|
||||
* (today's per-device setting, so existing users see no change), else the
|
||||
* admin default, else the layout's own, else the default look's.
|
||||
*/
|
||||
export function resolveThemeId(
|
||||
look: LookResponse | null,
|
||||
localTheme: string | null,
|
||||
layoutDefaultTheme?: string,
|
||||
): string {
|
||||
return (
|
||||
look?.themeId ??
|
||||
localTheme ??
|
||||
look?.defaults.theme ??
|
||||
layoutDefaultTheme ??
|
||||
look?.defaultLook.theme ??
|
||||
FALLBACK_THEME
|
||||
);
|
||||
}
|
||||
|
||||
/** ?safe-layout=1 in the address bar opens the repair view. */
|
||||
export function safeLayoutInUrl(search: string): boolean {
|
||||
const value = new URLSearchParams(search).get("safe-layout");
|
||||
return value === "1" || value === "true";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { arePluginThemesKnown, type ThemeDef } from "./theme-registry";
|
||||
|
||||
const LINK_ID = "termix-theme-css";
|
||||
/** The last plugin theme's stylesheet, so a reload shows it before login too. */
|
||||
const CACHE_KEY = "termix-theme-css";
|
||||
|
||||
function readCache(): { id: string; href: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
|
||||
if (
|
||||
parsed &&
|
||||
typeof (parsed as { id?: unknown }).id === "string" &&
|
||||
typeof (parsed as { href?: unknown }).href === "string"
|
||||
) {
|
||||
return parsed as { id: string; href: string };
|
||||
}
|
||||
} catch {
|
||||
// Unreadable or private mode.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeCache(value: { id: string; href: string } | null) {
|
||||
try {
|
||||
if (value) localStorage.setItem(CACHE_KEY, JSON.stringify(value));
|
||||
else localStorage.removeItem(CACHE_KEY);
|
||||
} catch {
|
||||
// Private mode; the theme still applies once plugins load.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the one plugin theme stylesheet the shown theme needs, and only that
|
||||
* one. Built-in themes need none. A theme whose plugin has not loaded yet
|
||||
* keeps the stylesheet it had last time.
|
||||
*/
|
||||
export function applyThemeCss(id: string, theme: ThemeDef | undefined): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const cached = readCache();
|
||||
let href: string | null = null;
|
||||
if (theme?.cssHref) {
|
||||
href = theme.cssHref;
|
||||
writeCache({ id, href });
|
||||
} else if (cached?.id === id && !arePluginThemesKnown()) {
|
||||
// Its plugin has not said yet whether it still provides this theme.
|
||||
href = cached.href;
|
||||
} else if (cached && arePluginThemesKnown()) {
|
||||
writeCache(null);
|
||||
}
|
||||
|
||||
let link = document.getElementById(LINK_ID) as HTMLLinkElement | null;
|
||||
if (!href) {
|
||||
link?.remove();
|
||||
return;
|
||||
}
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.id = LINK_ID;
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
const absolute = new URL(href, window.location.href).href;
|
||||
if (link.href !== absolute) link.href = absolute;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/** Follows the OS between the light and dark theme. Not a real theme. */
|
||||
export const SYSTEM_THEME = "system";
|
||||
|
||||
export interface ThemeSwatch {
|
||||
background: string;
|
||||
panel: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ThemeDef {
|
||||
/** Also the class put on <html> while the theme is active. */
|
||||
id: string;
|
||||
/** i18n key: core's for built-ins, the plugin namespace's for plugins. */
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
swatch: ThemeSwatch;
|
||||
/** Set for a plugin theme. */
|
||||
pluginId?: string;
|
||||
/** Where a plugin theme's CSS is served. Built-ins live in index.css. */
|
||||
cssHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two themes core keeps as a fallback, with their tokens in index.css.
|
||||
* The rest come from theme plugins. A plugin theme with the same id takes
|
||||
* the built-in's place while it is installed.
|
||||
*/
|
||||
export const BUILTIN_THEMES: readonly ThemeDef[] = [
|
||||
{
|
||||
id: "light",
|
||||
label: "newUi.sidebar.userProfile.themeLight",
|
||||
mode: "light",
|
||||
swatch: { background: "#ffffff", panel: "#eef0f3", text: "#1f2937" },
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
label: "newUi.sidebar.userProfile.themeDark",
|
||||
mode: "dark",
|
||||
swatch: { background: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
|
||||
},
|
||||
];
|
||||
|
||||
const builtins = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
|
||||
let pluginThemes = new Map<string, ThemeDef>();
|
||||
/** False until the plugin list has been read once this session. */
|
||||
let pluginThemesKnown = false;
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: ThemeDef[] | null = null;
|
||||
|
||||
function emit() {
|
||||
snapshot = null;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Built-ins in their order, a plugin theme in a built-in's place, then the rest as declared. */
|
||||
export function listThemes(): ThemeDef[] {
|
||||
if (!snapshot) {
|
||||
const replaced = BUILTIN_THEMES.map(
|
||||
(theme) => pluginThemes.get(theme.id) ?? theme,
|
||||
);
|
||||
const extra = [...pluginThemes.values()].filter(
|
||||
(theme) => !builtins.has(theme.id),
|
||||
);
|
||||
snapshot = [...replaced, ...extra];
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function getTheme(id: string): ThemeDef | undefined {
|
||||
return pluginThemes.get(id) ?? builtins.get(id);
|
||||
}
|
||||
|
||||
/** Replaces every plugin theme at once, from the plugin list. */
|
||||
export function setPluginThemes(themes: ThemeDef[]): void {
|
||||
const first = !pluginThemesKnown;
|
||||
pluginThemesKnown = true;
|
||||
const next = new Map(themes.map((theme) => [theme.id, theme]));
|
||||
const same =
|
||||
next.size === pluginThemes.size &&
|
||||
[...next].every(([id, theme]) => {
|
||||
const current = pluginThemes.get(id);
|
||||
return (
|
||||
current?.cssHref === theme.cssHref &&
|
||||
current?.pluginId === theme.pluginId &&
|
||||
current?.label === theme.label &&
|
||||
current?.mode === theme.mode
|
||||
);
|
||||
});
|
||||
if (same && !first) return;
|
||||
pluginThemes = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useThemes(): ThemeDef[] {
|
||||
return useSyncExternalStore(subscribeThemes, listThemes, listThemes);
|
||||
}
|
||||
|
||||
/** The light or dark mode a theme id stands for, following the OS for system. */
|
||||
export function themeMode(id: string): "light" | "dark" {
|
||||
if (id === SYSTEM_THEME) {
|
||||
return typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
const theme = getTheme(id);
|
||||
if (theme) return theme.mode;
|
||||
// A theme that went away follows the OS, once the plugins have said so.
|
||||
return pluginThemesKnown ? themeMode(SYSTEM_THEME) : "dark";
|
||||
}
|
||||
|
||||
/** The theme to show for a saved id: itself, or System when it is gone. */
|
||||
export function effectiveThemeId(id: string): string {
|
||||
if (id === SYSTEM_THEME || getTheme(id) || !pluginThemesKnown) return id;
|
||||
return SYSTEM_THEME;
|
||||
}
|
||||
|
||||
export function themeLabel(theme: ThemeDef, t: TFunction): string {
|
||||
if (!theme.pluginId) return t(theme.label);
|
||||
return t(`${theme.pluginId}:${theme.label}`, { defaultValue: theme.label });
|
||||
}
|
||||
|
||||
export function arePluginThemesKnown(): boolean {
|
||||
return pluginThemesKnown;
|
||||
}
|
||||
|
||||
/** On logout, and a test seam. */
|
||||
export function resetPluginThemes(): void {
|
||||
pluginThemes = new Map();
|
||||
pluginThemesKnown = false;
|
||||
emit();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useLayoutEffect } from "react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import { chooseThemeForAccount, useLookState } from "./look-store";
|
||||
import { getLayoutDescriptor, getRegisteredLayout } from "./layout-registry";
|
||||
import { resolveThemeId } from "./resolve-look";
|
||||
|
||||
const LOCAL_THEME_KEY = "vite-ui-theme";
|
||||
|
||||
function readLocalTheme(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LOCAL_THEME_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the theme the look settles on once it is known. A theme someone
|
||||
* picked themselves is kept in this browser too; a default is only shown,
|
||||
* so a later default change still reaches them.
|
||||
*/
|
||||
export function useLookTheme(layoutId: string): void {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const look = useLookState();
|
||||
const layoutDefault =
|
||||
getRegisteredLayout(layoutId)?.defaultTheme ??
|
||||
getLayoutDescriptor(layoutId)?.defaultTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!look.loaded) return;
|
||||
const next = resolveThemeId(look.response, readLocalTheme(), layoutDefault);
|
||||
if (next === theme) return;
|
||||
setTheme(next, !!look.response?.themeId);
|
||||
// Only when the look itself changes, not on every theme switch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [look, layoutDefault]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a theme for this user: shown now and kept in this browser. Unless
|
||||
* they keep settings on this device only, it is saved to their account too,
|
||||
* which is what other devices follow.
|
||||
*/
|
||||
export function useChooseTheme(): (themeId: string) => void {
|
||||
const { setTheme } = useTheme();
|
||||
return useCallback(
|
||||
(themeId: string) => {
|
||||
setTheme(themeId);
|
||||
void syncThemeChoice(themeId);
|
||||
},
|
||||
[setTheme],
|
||||
);
|
||||
}
|
||||
|
||||
async function syncThemeChoice(themeId: string): Promise<void> {
|
||||
const prefs = await getUserPreferences().catch(() => null);
|
||||
// A device-only profile must not be overridden by an account choice.
|
||||
const local = prefs?.storageMode === "local";
|
||||
await chooseThemeForAccount(local ? null : themeId).catch(() => {});
|
||||
// Older clients still read the profile field.
|
||||
if (prefs?.storageMode === "cloud") {
|
||||
await saveUserPreferences({ theme: themeId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
+5
-10
@@ -877,14 +877,12 @@ export {
|
||||
getSSHHosts,
|
||||
createSSHHost,
|
||||
updateSSHHost,
|
||||
bulkImportSSHHosts,
|
||||
importSSHConfigHosts,
|
||||
bulkUpdateSSHHosts,
|
||||
reorderSSHHosts,
|
||||
getHostPolicy,
|
||||
type HostPolicy,
|
||||
deleteSSHHost,
|
||||
getSSHHostById,
|
||||
exportSSHHostWithCredentials,
|
||||
exportAllSSHHosts,
|
||||
testProxyConnection,
|
||||
} from "@/api/ssh-host-management-api";
|
||||
|
||||
@@ -913,6 +911,9 @@ export {
|
||||
updateLogLevel,
|
||||
getSessionTimeout,
|
||||
updateSessionTimeout,
|
||||
getHttpsSettings,
|
||||
updateHttpsSettings,
|
||||
type HttpsSettings,
|
||||
} from "@/api/settings-api";
|
||||
|
||||
// ============================================================================
|
||||
@@ -1280,12 +1281,6 @@ export {
|
||||
deleteAllHostsInFolder,
|
||||
renameCredentialFolder,
|
||||
reorderCredentials,
|
||||
detectKeyType,
|
||||
detectPublicKeyType,
|
||||
validateKeyPair,
|
||||
generatePublicKeyFromPrivate,
|
||||
generateKeyPair,
|
||||
deployCredentialToHost,
|
||||
} from "@/api/credentials-api";
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import { GroupHeading, PANEL, PanelSearch } from "@/components/panel-layout";
|
||||
import { TabStrip } from "@/components/tab-strip";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { HIDDEN_ATTR, useSettingsPageFilter } from "@/settings/page-filter";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** True while the editor is searched, so every lazy section mounts. */
|
||||
const SearchingContext = createContext(false);
|
||||
|
||||
export interface EditorNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
/** Marks a section holding something that needs fixing. */
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorNavBand {
|
||||
id: string;
|
||||
label: string;
|
||||
items: EditorNavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that really scrolls: the editor's own area, or the page around
|
||||
* it when the editor is embedded somewhere that scrolls itself.
|
||||
*/
|
||||
function scroller(el: HTMLElement): HTMLElement {
|
||||
let node: HTMLElement | null = el;
|
||||
while (node) {
|
||||
const overflow = getComputedStyle(node).overflowY;
|
||||
if (
|
||||
(overflow === "auto" || overflow === "scroll") &&
|
||||
node.scrollHeight > node.clientHeight + 1
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Which section is in view, and a way to jump to one. */
|
||||
export function useScrollSpy(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
ids: string[],
|
||||
): { active: string; scrollTo: (id: string) => void } {
|
||||
const [active, setActive] = useState(ids[0] ?? "");
|
||||
const idsKey = ids.join("|");
|
||||
// A jump scrolls past every section between; the spy waits it out.
|
||||
const lockUntil = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const onScroll = () => {
|
||||
if (Date.now() < lockUntil.current) return;
|
||||
const area = scroller(container);
|
||||
const top = area.getBoundingClientRect().top;
|
||||
let current = ids[0] ?? "";
|
||||
for (const id of ids) {
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-section="${id}"]`,
|
||||
);
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top - top <= 24) current = id;
|
||||
}
|
||||
if (
|
||||
area.scrollHeight > area.clientHeight &&
|
||||
area.scrollTop + area.clientHeight >= area.scrollHeight - 8
|
||||
) {
|
||||
current = ids[ids.length - 1] ?? current;
|
||||
}
|
||||
setActive(current);
|
||||
};
|
||||
onScroll();
|
||||
// Captured on the document so an outer scroller is heard too.
|
||||
document.addEventListener("scroll", onScroll, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
return () =>
|
||||
document.removeEventListener("scroll", onScroll, { capture: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [containerRef, idsKey]);
|
||||
|
||||
return {
|
||||
active: ids.includes(active) ? active : (ids[0] ?? ""),
|
||||
scrollTo: (id: string) => {
|
||||
const container = containerRef.current;
|
||||
const el = container?.querySelector<HTMLElement>(
|
||||
`[data-section="${id}"]`,
|
||||
);
|
||||
if (!container || !el) return;
|
||||
lockUntil.current = Date.now() + 600;
|
||||
setActive(id);
|
||||
const area = scroller(container);
|
||||
const target = () =>
|
||||
el.getBoundingClientRect().top -
|
||||
area.getBoundingClientRect().top +
|
||||
area.scrollTop -
|
||||
8;
|
||||
let lastTop = target();
|
||||
let lastHeight = area.scrollHeight;
|
||||
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
|
||||
// Sections above can still be mounting, which moves the target or
|
||||
// cuts the scroll short. Follow it until the scroll lands and the
|
||||
// layout stops changing, unless the user scrolls first.
|
||||
const started = Date.now();
|
||||
let changedAt = started;
|
||||
let userScrolled = false;
|
||||
const onUser = () => {
|
||||
userScrolled = true;
|
||||
};
|
||||
area.addEventListener("wheel", onUser, { passive: true });
|
||||
area.addEventListener("touchstart", onUser, { passive: true });
|
||||
const done = () => {
|
||||
area.removeEventListener("wheel", onUser);
|
||||
area.removeEventListener("touchstart", onUser);
|
||||
};
|
||||
const follow = () => {
|
||||
const now = Date.now();
|
||||
if (userScrolled || !el.isConnected || now - started > 5000) {
|
||||
return done();
|
||||
}
|
||||
const top = target();
|
||||
// Keep the spy quiet for the whole trip, not just the first scroll.
|
||||
lockUntil.current = now + 600;
|
||||
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
|
||||
lastTop = top;
|
||||
lastHeight = area.scrollHeight;
|
||||
changedAt = now;
|
||||
area.scrollTo?.({ top, behavior: "smooth" });
|
||||
}
|
||||
const reachable = Math.min(
|
||||
lastTop,
|
||||
area.scrollHeight - area.clientHeight,
|
||||
);
|
||||
if (
|
||||
Math.abs(area.scrollTop - reachable) <= 2 &&
|
||||
now - changedAt > 400
|
||||
) {
|
||||
setActive(id);
|
||||
return done();
|
||||
}
|
||||
requestAnimationFrame(follow);
|
||||
};
|
||||
requestAnimationFrame(follow);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves once the element's height holds still for a few frames. */
|
||||
function untilLayoutSettles(el: HTMLElement | null, max = 800): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!el || typeof requestAnimationFrame === "undefined") return resolve();
|
||||
const started = Date.now();
|
||||
let last = -1;
|
||||
let still = 0;
|
||||
const check = () => {
|
||||
const height = el.scrollHeight;
|
||||
still = height === last ? still + 1 : 0;
|
||||
last = height;
|
||||
if (still >= 3 || Date.now() - started > max) return resolve();
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wraps a section so the nav can find it. */
|
||||
export function EditorSection({
|
||||
id,
|
||||
label,
|
||||
showHeading = true,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label?: string;
|
||||
showHeading?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section data-section={id} className={`flex flex-col ${PANEL.gap}`}>
|
||||
{showHeading && label && <GroupHeading title={label} className="pt-1" />}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts its children the first time they scroll near view, so a long editor
|
||||
* does not start every section's requests at once.
|
||||
*/
|
||||
export function LazySection({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const searching = useContext(SearchingContext);
|
||||
const [shown, setShown] = useState(
|
||||
() => typeof IntersectionObserver === "undefined",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (searching) setShown(true);
|
||||
}, [searching]);
|
||||
useLayoutEffect(() => {
|
||||
if (shown) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setShown(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "400px 0px" },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [shown]);
|
||||
return (
|
||||
<div ref={ref} className={shown ? "contents" : "min-h-24"}>
|
||||
{shown && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An editor laid out like the settings screen: a section nav beside one
|
||||
* scrolling form, and the actions pinned under it.
|
||||
*/
|
||||
export function EditorPane({
|
||||
bands,
|
||||
banner,
|
||||
footer,
|
||||
errorCount = 0,
|
||||
revealRef,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
bands: EditorNavBand[];
|
||||
/** Filled with a function that scrolls a section into view. */
|
||||
revealRef?: React.MutableRefObject<((id: string) => void) | null>;
|
||||
/** Sits above the form, inside the scroll area. */
|
||||
banner?: ReactNode;
|
||||
/** Usually Cancel and Save, pinned at the bottom. */
|
||||
footer?: ReactNode;
|
||||
/** Shown beside the footer so a problem in a hidden section is not missed. */
|
||||
errorCount?: number;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
|
||||
const searching = query.trim().length > 0;
|
||||
const onFiltered = useCallback((root: HTMLElement) => {
|
||||
const next = new Set<string>();
|
||||
for (const el of root.querySelectorAll<HTMLElement>(
|
||||
`[data-section][${HIDDEN_ATTR}]`,
|
||||
)) {
|
||||
if (el.dataset.section) next.add(el.dataset.section);
|
||||
}
|
||||
setHiddenIds((prev) =>
|
||||
prev.size === next.size && [...next].every((id) => prev.has(id))
|
||||
? prev
|
||||
: next,
|
||||
);
|
||||
}, []);
|
||||
const anyShown = useSettingsPageFilter(
|
||||
formRef,
|
||||
query,
|
||||
"editor",
|
||||
"[data-section]",
|
||||
onFiltered,
|
||||
);
|
||||
const visibleBands = bands
|
||||
.map((band) => ({
|
||||
...band,
|
||||
items: band.items.filter((item) => !hiddenIds.has(item.id)),
|
||||
}))
|
||||
.filter((band) => band.items.length > 0);
|
||||
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
|
||||
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
|
||||
const showNav = bands.flatMap((band) => band.items).length > 1;
|
||||
// A jump mounts every lazy section first and scrolls once the form stops
|
||||
// growing. Mounting them on the way down made the scroll stall and restart.
|
||||
const [mountAll, setMountAll] = useState(false);
|
||||
const jump = (id: string) => {
|
||||
if (mountAll) return scrollTo(id);
|
||||
setMountAll(true);
|
||||
void untilLayoutSettles(scrollRef.current).then(() => scrollTo(id));
|
||||
};
|
||||
if (revealRef) revealRef.current = jump;
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-1", className)}>
|
||||
{showNav && (
|
||||
<nav
|
||||
aria-label={t("manage.sections")}
|
||||
className="hidden w-52 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 thin-scrollbar lg:flex"
|
||||
>
|
||||
{visibleBands.map((band) => (
|
||||
<div key={band.id} className="flex flex-col">
|
||||
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
|
||||
{band.items.map((item) => {
|
||||
const on = item.id === active;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={on ? "true" : undefined}
|
||||
onClick={() => jump(item.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
on
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex shrink-0 [&_svg]:size-3.5">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-xs font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.error && (
|
||||
<span
|
||||
title={t("manage.sectionNeedsAttention")}
|
||||
className="ml-auto size-1.5 shrink-0 rounded-full bg-destructive"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("manage.searchSettings")}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showNav && (
|
||||
<div className="shrink-0 border-b border-border px-1 lg:hidden">
|
||||
<TabStrip
|
||||
tabs={visibleBands.flatMap((band) =>
|
||||
band.items.map((item) => ({ id: item.id, label: item.label })),
|
||||
)}
|
||||
activeTab={active}
|
||||
onTabChange={jump}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
|
||||
>
|
||||
{!anyShown && (
|
||||
<EmptyState icon={Search} title={t("manage.noMatchingSettings")} />
|
||||
)}
|
||||
<div
|
||||
ref={formRef}
|
||||
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
||||
>
|
||||
{banner}
|
||||
<SearchingContext.Provider value={searching || mountAll}>
|
||||
{children}
|
||||
</SearchingContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
{(footer || errorCount > 0) && (
|
||||
<div className="flex shrink-0 items-center gap-3 border-t border-border px-3 py-2">
|
||||
{errorCount > 0 && (
|
||||
<span className="text-[10px] text-destructive">
|
||||
{t("manage.needsAttention", { count: errorCount })}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">{footer}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PanelSearch } from "@/components/panel-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { rem } from "@/lib/rem";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
import { defaultsRows, type DefaultsRow } from "./defaults-rows";
|
||||
import {
|
||||
allFolderPaths,
|
||||
allParentIds,
|
||||
buildCredentialManageTree,
|
||||
buildHostManageTree,
|
||||
flattenTree,
|
||||
type ManageRow,
|
||||
} from "./manage-tree";
|
||||
import type { ManageMode } from "./manage-requests";
|
||||
import {
|
||||
getStatusClasses,
|
||||
useStatusColorScheme,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
|
||||
function indent(depth: number) {
|
||||
return 6 + depth * 13;
|
||||
}
|
||||
|
||||
/** Rules marking each open ancestor, so deep rows still read as nested. */
|
||||
function Guides({ depth }: { depth: number }) {
|
||||
if (depth === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: depth }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 w-px bg-border"
|
||||
style={{ left: rem(indent(i) + 7) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rowFocus =
|
||||
"focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring";
|
||||
|
||||
/** The list column of the Manage tab. */
|
||||
export function ManageList({
|
||||
editing,
|
||||
mode,
|
||||
onMode,
|
||||
hosts,
|
||||
credentials,
|
||||
canEditInstanceDefaults,
|
||||
isOnline,
|
||||
selectedKey,
|
||||
onPickHost,
|
||||
onPickCredential,
|
||||
onPickDefaults,
|
||||
onAdd,
|
||||
}: {
|
||||
editing: boolean;
|
||||
mode: ManageMode;
|
||||
onMode: (mode: ManageMode) => void;
|
||||
hosts: Host[];
|
||||
credentials: Credential[];
|
||||
canEditInstanceDefaults: boolean;
|
||||
isOnline: (host: Host) => boolean;
|
||||
/** "h:<id>", "c:<id>" or a defaults row key. */
|
||||
selectedKey: string | null;
|
||||
onPickHost: (host: Host) => void;
|
||||
onPickCredential: (credential: Credential) => void;
|
||||
onPickDefaults: (row: DefaultsRow) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const needle = query.trim().toLowerCase();
|
||||
|
||||
const tree = useMemo(
|
||||
() =>
|
||||
mode === "credentials"
|
||||
? buildCredentialManageTree(credentials)
|
||||
: buildHostManageTree(hosts, isOnline),
|
||||
[mode, hosts, credentials, isOnline],
|
||||
);
|
||||
|
||||
// Everything starts open; closing something is remembered while mounted.
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
setOpen(new Set([...allFolderPaths(tree), ...allParentIds(tree)]));
|
||||
// Rebuilt only when the side changes, so toggles survive an edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode]);
|
||||
|
||||
const toggle = (key: string) =>
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const rows = useMemo(
|
||||
() => (mode === "defaults" ? [] : flattenTree(tree, open, needle)),
|
||||
[mode, tree, open, needle],
|
||||
);
|
||||
|
||||
const defaults = useMemo(
|
||||
() =>
|
||||
defaultsRows(hosts, canEditInstanceDefaults, t).filter(
|
||||
(row) => !needle || row.label.toLowerCase().includes(needle),
|
||||
),
|
||||
[hosts, canEditInstanceDefaults, t, needle],
|
||||
);
|
||||
|
||||
const pick = (id: string) => {
|
||||
if (mode === "hosts") {
|
||||
const host = hosts.find((h) => h.id === id);
|
||||
if (host) onPickHost(host);
|
||||
} else {
|
||||
const credential = credentials.find((c) => c.id === id);
|
||||
if (credential) onPickCredential(credential);
|
||||
}
|
||||
};
|
||||
|
||||
const empty = mode === "defaults" ? defaults.length === 0 : rows.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
editing ? "hidden md:flex" : "flex",
|
||||
"w-full shrink-0 flex-col border-r border-border bg-background md:w-72",
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
aria-label={t("manage.sections")}
|
||||
className="flex shrink-0 flex-col border-b border-border py-1"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: "hosts",
|
||||
label: t("nav.hosts"),
|
||||
icon: Server,
|
||||
count: hosts.length,
|
||||
},
|
||||
{
|
||||
value: "credentials",
|
||||
label: t("nav.credentials"),
|
||||
icon: KeyRound,
|
||||
count: credentials.length,
|
||||
},
|
||||
{
|
||||
value: "defaults",
|
||||
label: t("manage.defaults"),
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
] as {
|
||||
value: ManageMode;
|
||||
label: string;
|
||||
icon: typeof Server;
|
||||
count?: number;
|
||||
}[]
|
||||
).map((item) => {
|
||||
const active = item.value === mode;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onMode(item.value)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2.5 text-left transition-colors",
|
||||
rowFocus,
|
||||
active
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.count !== undefined && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums opacity-60">
|
||||
{item.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={
|
||||
mode === "hosts"
|
||||
? t("manage.searchHosts")
|
||||
: mode === "credentials"
|
||||
? t("manage.searchCredentials")
|
||||
: t("manage.searchDefaults")
|
||||
}
|
||||
fill
|
||||
/>
|
||||
{mode !== "defaults" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={
|
||||
mode === "hosts"
|
||||
? t("hosts.addHost")
|
||||
: t("manage.addCredential")
|
||||
}
|
||||
aria-label={
|
||||
mode === "hosts"
|
||||
? t("hosts.addHost")
|
||||
: t("manage.addCredential")
|
||||
}
|
||||
onClick={onAdd}
|
||||
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-1 thin-scrollbar">
|
||||
{empty ? (
|
||||
<EmptyState
|
||||
icon={mode === "credentials" ? KeyRound : Server}
|
||||
title={needle ? t("manage.nothingMatches") : t("manage.nothingYet")}
|
||||
hint={
|
||||
needle
|
||||
? undefined
|
||||
: mode === "hosts"
|
||||
? t("manage.emptyHostsHint")
|
||||
: t("manage.emptyCredentialsHint")
|
||||
}
|
||||
/>
|
||||
) : mode === "defaults" ? (
|
||||
defaults.map((row) => {
|
||||
const Icon =
|
||||
row.level === "admin"
|
||||
? Globe
|
||||
: row.level === "user"
|
||||
? User
|
||||
: Folder;
|
||||
const selected = selectedKey === row.key;
|
||||
return (
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
onClick={() => onPickDefaults(row)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-l-2 px-2.5 py-1.5 text-left transition-colors",
|
||||
rowFocus,
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{row.label}
|
||||
</span>
|
||||
<SlidersHorizontal className="size-3 shrink-0 text-muted-foreground/60" />
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
rows.map((row) =>
|
||||
row.kind === "folder" ? (
|
||||
<FolderRow
|
||||
key={`f:${row.folder.path}`}
|
||||
row={row}
|
||||
showOnline={mode === "hosts"}
|
||||
open={!!needle || open.has(row.folder.path)}
|
||||
locked={!!needle}
|
||||
onToggle={() => toggle(row.folder.path)}
|
||||
/>
|
||||
) : (
|
||||
<ItemRow
|
||||
key={`i:${row.item.id}`}
|
||||
row={row}
|
||||
isHost={mode === "hosts"}
|
||||
selected={
|
||||
selectedKey ===
|
||||
`${mode === "hosts" ? "h" : "c"}:${row.item.id}`
|
||||
}
|
||||
open={!!needle || open.has(row.item.id)}
|
||||
locked={!!needle}
|
||||
onToggle={() => toggle(row.item.id)}
|
||||
onPick={() => pick(row.item.id)}
|
||||
/>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({
|
||||
row,
|
||||
open,
|
||||
locked,
|
||||
showOnline,
|
||||
onToggle,
|
||||
}: {
|
||||
row: Extract<ManageRow, { kind: "folder" }>;
|
||||
open: boolean;
|
||||
/** Search forces folders open, so the chevron stops responding. */
|
||||
locked: boolean;
|
||||
showOnline: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const Icon = open ? FolderOpen : Folder;
|
||||
return (
|
||||
<div className="relative">
|
||||
<Guides depth={row.depth} />
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={locked ? undefined : onToggle}
|
||||
style={{ paddingLeft: rem(indent(row.depth)) }}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1.5 py-1.5 pr-2.5 text-left transition-colors hover:bg-muted",
|
||||
rowFocus,
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{row.folder.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">
|
||||
{showOnline ? `${row.online}/${row.count}` : row.count}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemRow({
|
||||
row,
|
||||
isHost,
|
||||
selected,
|
||||
open,
|
||||
locked,
|
||||
onToggle,
|
||||
onPick,
|
||||
}: {
|
||||
row: Extract<ManageRow, { kind: "item" }>;
|
||||
isHost: boolean;
|
||||
selected: boolean;
|
||||
open: boolean;
|
||||
locked: boolean;
|
||||
onToggle: () => void;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useStatusColorScheme();
|
||||
const nests = row.item.children.length > 0;
|
||||
const Icon = isHost ? Server : KeyRound;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Guides depth={row.depth} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center border-l-2 transition-colors",
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-transparent hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{ paddingLeft: rem(indent(row.depth)) }}
|
||||
>
|
||||
{nests ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={locked ? undefined : onToggle}
|
||||
aria-expanded={open}
|
||||
title={open ? t("manage.collapse") : t("manage.expand")}
|
||||
aria-label={open ? t("manage.collapse") : t("manage.expand")}
|
||||
className={cn(
|
||||
"flex size-4 items-center justify-center text-muted-foreground hover:text-foreground",
|
||||
rowFocus,
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 py-1.5 pl-1 pr-2.5 text-left transition-colors",
|
||||
rowFocus,
|
||||
selected ? "text-accent-brand" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{row.item.name}
|
||||
</span>
|
||||
<span className="shrink-0 truncate font-mono text-[10px] text-muted-foreground">
|
||||
{row.item.note}
|
||||
</span>
|
||||
{isHost && (
|
||||
<span
|
||||
aria-hidden
|
||||
title={row.item.online ? t("common.online") : t("common.offline")}
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
getStatusClasses(!!row.item.online, scheme, "dot"),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound, LibraryBig, Plus, Server, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { HostDraft } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import { Button } from "@/components/button";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { FakeSwitch } from "@/components/section-card";
|
||||
import { BackButton, PanelShell } from "@/components/panel-layout";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { useUiPreference } from "@/contexts/UiPreferencesContext";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
deleteCredential,
|
||||
deleteSSHHost,
|
||||
getCredentialDetails,
|
||||
getCredentials,
|
||||
getSSHHosts,
|
||||
type SSHHostWithStatus,
|
||||
} from "@/main-axios";
|
||||
import { getFolderDefaultsId } from "@/api/host-defaults-api";
|
||||
import { invalidateHostsAndStatusCaches } from "@/lib/hosts-request-cache";
|
||||
import { registerTabCloseGuard } from "@/shell/tab-close-guards";
|
||||
import { HostEditor } from "@/sidebar/HostEditor";
|
||||
import { CredentialEditorView } from "@/sidebar/CredentialEditorView";
|
||||
import {
|
||||
HostDefaultsEditorView,
|
||||
hostDefaultsTitle,
|
||||
type HostDefaultsEditorTarget,
|
||||
} from "@/sidebar/host-defaults/HostDefaultsEditorView";
|
||||
import { mapCredentials, sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import {
|
||||
hostProtocolFlags,
|
||||
type HostProtocols,
|
||||
} from "@/sidebar/host-protocols";
|
||||
import { canDeleteHost } from "@/sidebar/host-permissions";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
import { ManageList } from "./ManageList";
|
||||
import type { DefaultsRow } from "./defaults-rows";
|
||||
import {
|
||||
MANAGE_REQUEST_EVENT,
|
||||
takePendingManageRequest,
|
||||
type ManageMode,
|
||||
type ManageRequest,
|
||||
} from "./manage-requests";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
type Editing =
|
||||
| {
|
||||
kind: "host";
|
||||
host: Host | null;
|
||||
draft?: HostDraft;
|
||||
key: number;
|
||||
protocols: HostProtocols;
|
||||
}
|
||||
| { kind: "credential"; credential: Credential | null; key: number }
|
||||
| { kind: "defaults"; target: HostDefaultsEditorTarget; rowKey: string };
|
||||
|
||||
const TAB_ID = "host-manager";
|
||||
|
||||
/** Fetches a credential with its secrets marked as stored, for the editor. */
|
||||
async function loadCredentialForEdit(cred: Credential): Promise<Credential> {
|
||||
try {
|
||||
const full = (await getCredentialDetails(Number(cred.id))) as {
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
password?: string;
|
||||
certPublicKey?: string;
|
||||
};
|
||||
return {
|
||||
...cred,
|
||||
value: full.hasKey ? "existing_key" : (full.password ?? ""),
|
||||
password: full.password ?? "",
|
||||
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
|
||||
publicKey: full.certPublicKey ?? cred.publicKey,
|
||||
};
|
||||
} catch {
|
||||
return cred;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts, credentials and host defaults, edited at full width: a list on the
|
||||
* left, the thing you picked on the right.
|
||||
*/
|
||||
export function ManageTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { has } = usePermissions();
|
||||
const { statuses } = useServerStatus();
|
||||
const presetMode = useUiPreference("hostEditor", "mode");
|
||||
|
||||
const [mode, setMode] = useState<ManageMode>("hosts");
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [editing, setEditing] = useState<Editing | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
// Starts from the preset and stays where the user puts it this session.
|
||||
const [advanced, setAdvanced] = useState(presetMode !== "simple");
|
||||
// The host a credential was opened from, so Back returns to it.
|
||||
const [returnHost, setReturnHost] = useState<Host | "new" | null>(null);
|
||||
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const credentialsRef = useRef(credentials);
|
||||
credentialsRef.current = credentials;
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
|
||||
const reloadHosts = useCallback(() => {
|
||||
return getSSHHosts()
|
||||
.then((raw) => {
|
||||
const list = raw.map(sshHostToHost);
|
||||
hostsRef.current = list;
|
||||
setHosts(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const reloadCredentials = useCallback(() => {
|
||||
return getCredentials()
|
||||
.then((res) => {
|
||||
const list = mapCredentials(res);
|
||||
credentialsRef.current = list;
|
||||
setCredentials(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loaded = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
loaded.current = Promise.all([reloadHosts(), reloadCredentials()]);
|
||||
}, [reloadHosts, reloadCredentials]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.addEventListener("ssh-hosts:changed", reloadHosts);
|
||||
window.addEventListener("hosts:refresh", reloadHosts);
|
||||
window.addEventListener("termix:credentials-changed", reloadCredentials);
|
||||
return () => {
|
||||
window.removeEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.removeEventListener("ssh-hosts:changed", reloadHosts);
|
||||
window.removeEventListener("hosts:refresh", reloadHosts);
|
||||
window.removeEventListener(
|
||||
"termix:credentials-changed",
|
||||
reloadCredentials,
|
||||
);
|
||||
};
|
||||
}, [reloadHosts, reloadCredentials]);
|
||||
|
||||
/** Asks before throwing away unsaved changes. True means go ahead. */
|
||||
const confirmDiscard = useCallback(async () => {
|
||||
if (!dirtyRef.current) return true;
|
||||
return confirm({
|
||||
title: t("common.unsavedChanges"),
|
||||
description: t("hosts.unsavedChangesDescription"),
|
||||
confirmLabel: t("hosts.discardChanges"),
|
||||
cancelLabel: t("hosts.keepEditing"),
|
||||
});
|
||||
}, [confirm, t]);
|
||||
|
||||
useEffect(
|
||||
() => registerTabCloseGuard(TAB_ID, confirmDiscard),
|
||||
[confirmDiscard],
|
||||
);
|
||||
|
||||
const openHost = useCallback((host: Host | null, draft?: HostDraft) => {
|
||||
setMode("hosts");
|
||||
setReturnHost(null);
|
||||
setDirty(false);
|
||||
setEditing({
|
||||
kind: "host",
|
||||
host,
|
||||
draft,
|
||||
key: Date.now(),
|
||||
protocols: hostProtocolFlags(host),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openCredential = useCallback(async (credential: Credential | null) => {
|
||||
setMode("credentials");
|
||||
setDirty(false);
|
||||
const full = credential ? await loadCredentialForEdit(credential) : null;
|
||||
setEditing({ kind: "credential", credential: full, key: Date.now() });
|
||||
}, []);
|
||||
|
||||
const openDefaults = useCallback(
|
||||
async (row: Pick<DefaultsRow, "level" | "folderName">) => {
|
||||
setMode("defaults");
|
||||
setReturnHost(null);
|
||||
setDirty(false);
|
||||
const rowKey =
|
||||
row.level === "folder" ? `folder:${row.folderName}` : row.level;
|
||||
if (row.level === "folder" && row.folderName) {
|
||||
try {
|
||||
const folderId = await getFolderDefaultsId(row.folderName);
|
||||
setEditing({
|
||||
kind: "defaults",
|
||||
rowKey,
|
||||
target: { level: "folder", folderId, folderName: row.folderName },
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("hostDefaults.loadFailed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setEditing({ kind: "defaults", rowKey, target: { level: row.level } });
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const apply = useCallback(
|
||||
async (request: ManageRequest | null) => {
|
||||
if (!request) return;
|
||||
if (!(await confirmDiscard())) return;
|
||||
await loaded.current;
|
||||
if (request.kind === "host") {
|
||||
const find = () =>
|
||||
hostsRef.current.find((h) => h.id === request.hostId) ?? null;
|
||||
let host = request.hostId ? find() : null;
|
||||
// Our list can lag the sidebar's, so refetch once before giving up.
|
||||
if (request.hostId && !host) {
|
||||
invalidateHostsAndStatusCaches();
|
||||
await reloadHosts();
|
||||
host = find();
|
||||
if (!host) {
|
||||
toast.error(t("manage.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
openHost(host, request.draft);
|
||||
} else if (request.kind === "credential") {
|
||||
const find = () =>
|
||||
credentialsRef.current.find(
|
||||
(c) => String(c.id) === String(request.credentialId),
|
||||
) ?? null;
|
||||
let credential = request.credentialId ? find() : null;
|
||||
if (request.credentialId && !credential) {
|
||||
await reloadCredentials();
|
||||
credential = find();
|
||||
}
|
||||
void openCredential(credential);
|
||||
} else if (request.kind === "defaults") {
|
||||
void openDefaults(request);
|
||||
} else {
|
||||
setDirty(false);
|
||||
setEditing(null);
|
||||
setMode(request.mode);
|
||||
}
|
||||
},
|
||||
[
|
||||
confirmDiscard,
|
||||
openHost,
|
||||
openCredential,
|
||||
openDefaults,
|
||||
reloadHosts,
|
||||
reloadCredentials,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// The request that opened this tab was parked before it mounted.
|
||||
useEffect(() => {
|
||||
void apply(takePendingManageRequest());
|
||||
const onRequest = () => void apply(takePendingManageRequest());
|
||||
window.addEventListener(MANAGE_REQUEST_EVENT, onRequest);
|
||||
return () => window.removeEventListener(MANAGE_REQUEST_EVENT, onRequest);
|
||||
}, [apply]);
|
||||
|
||||
const guarded =
|
||||
<A extends unknown[]>(fn: (...args: A) => void) =>
|
||||
async (...args: A) => {
|
||||
if (await confirmDiscard()) fn(...args);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setDirty(false);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const backFromCredential = () => {
|
||||
if (returnHost) {
|
||||
const host = returnHost;
|
||||
setReturnHost(null);
|
||||
openHost(host === "new" ? null : host);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
const editCredentialFromHost = async (credentialId: string) => {
|
||||
const cred = credentials.find((c) => String(c.id) === String(credentialId));
|
||||
if (!cred || editing?.kind !== "host") return;
|
||||
if (!(await confirmDiscard())) return;
|
||||
setReturnHost(editing.host ?? "new");
|
||||
await openCredential(cred);
|
||||
};
|
||||
|
||||
const deleteCurrent = async () => {
|
||||
if (editing?.kind === "host" && editing.host) {
|
||||
const host = editing.host;
|
||||
const ok = await confirm({
|
||||
title: t("hosts.deleteHostConfirm", { name: host.name || host.ip }),
|
||||
description: t("manage.cannotBeUndone"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteSSHHost(Number(host.id));
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
toast.success(t("hosts.deletedCount", { count: 1 }));
|
||||
close();
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToDeleteCount", { count: 1 }));
|
||||
}
|
||||
} else if (editing?.kind === "credential" && editing.credential) {
|
||||
const cred = editing.credential;
|
||||
const ok = await confirm({
|
||||
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
|
||||
description: t("manage.cannotBeUndone"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteCredential(Number(cred.id));
|
||||
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
toast.success(t("credentials.deletedCredential", { name: cred.name }));
|
||||
close();
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToDeleteCredential"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isOnline = useCallback(
|
||||
(host: Host) => statuses.get(Number(host.id))?.status === "online",
|
||||
[statuses],
|
||||
);
|
||||
|
||||
const existingFolders = useMemo(
|
||||
() =>
|
||||
[
|
||||
...new Set(
|
||||
credentials.map((c) => c.folder).filter((f): f is string => !!f),
|
||||
),
|
||||
].sort(),
|
||||
[credentials],
|
||||
);
|
||||
|
||||
const selectedKey = !editing
|
||||
? null
|
||||
: editing.kind === "host"
|
||||
? editing.host
|
||||
? `h:${editing.host.id}`
|
||||
: null
|
||||
: editing.kind === "credential"
|
||||
? editing.credential
|
||||
? `c:${editing.credential.id}`
|
||||
: null
|
||||
: editing.rowKey;
|
||||
|
||||
const status = !editing
|
||||
? mode === "hosts"
|
||||
? t("manage.hostCount", { count: hosts.length })
|
||||
: mode === "credentials"
|
||||
? t("manage.credentialCount", { count: credentials.length })
|
||||
: t("manage.defaults")
|
||||
: editing.kind === "host"
|
||||
? editing.host
|
||||
? editing.host.name || editing.host.ip
|
||||
: t("manage.newHost")
|
||||
: editing.kind === "credential"
|
||||
? editing.credential
|
||||
? editing.credential.name
|
||||
: t("manage.newCredential")
|
||||
: hostDefaultsTitle(editing.target, t);
|
||||
|
||||
const canDelete =
|
||||
(editing?.kind === "host" &&
|
||||
!!editing.host &&
|
||||
canDeleteHost(editing.host)) ||
|
||||
(editing?.kind === "credential" && !!editing.credential);
|
||||
|
||||
const renderEditor = () => {
|
||||
if (!editing) return null;
|
||||
if (editing.kind === "defaults") {
|
||||
return (
|
||||
<HostDefaultsEditorView
|
||||
key={editing.rowKey}
|
||||
target={editing.target}
|
||||
onClose={guarded(close)}
|
||||
onDirtyChange={setDirty}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (editing.kind === "credential") {
|
||||
return (
|
||||
<CredentialEditorView
|
||||
key={`c:${editing.credential?.id ?? "new"}:${editing.key}`}
|
||||
credential={editing.credential}
|
||||
existingFolders={existingFolders}
|
||||
saveAsNewHost={returnHost ?? undefined}
|
||||
onBack={backFromCredential}
|
||||
onSave={(saved, options) => {
|
||||
const id = String((saved as { id: number | string }).id);
|
||||
void reloadCredentials();
|
||||
if (options?.assignToHost && returnHost) {
|
||||
const host = returnHost;
|
||||
setReturnHost(null);
|
||||
setDirty(false);
|
||||
setMode("hosts");
|
||||
const next =
|
||||
host === "new" ? null : { ...host, credentialId: id };
|
||||
setEditing({
|
||||
kind: "host",
|
||||
host: next,
|
||||
key: Date.now(),
|
||||
protocols: hostProtocolFlags(next),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (returnHost) {
|
||||
backFromCredential();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HostEditor
|
||||
key={`h:${editing.host?.id ?? "new"}:${editing.key}`}
|
||||
host={editing.host}
|
||||
draft={editing.host ? undefined : editing.draft}
|
||||
advanced={advanced}
|
||||
onBack={guarded(close)}
|
||||
onSave={(saved) => {
|
||||
const updated = sshHostToHost(saved as unknown as SSHHostWithStatus);
|
||||
setHosts((prev) => {
|
||||
const idx = prev.findIndex((h) => h.id === updated.id);
|
||||
if (idx < 0) return [...prev, updated];
|
||||
const next = [...prev];
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
close();
|
||||
}}
|
||||
protocols={editing.protocols}
|
||||
onProtocolChange={(p) =>
|
||||
setEditing((current) =>
|
||||
current?.kind === "host"
|
||||
? { ...current, protocols: { ...current.protocols, ...p } }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onDirtyChange={setDirty}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
onEditCredential={(id) => void editCredentialFromHost(id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
icon={<LibraryBig className="size-4" />}
|
||||
title={t("nav.manage")}
|
||||
status={status}
|
||||
scroll={false}
|
||||
docs={docsUrl(
|
||||
mode === "credentials"
|
||||
? "credentials"
|
||||
: mode === "hosts"
|
||||
? "hosts"
|
||||
: "hostDefaults",
|
||||
)}
|
||||
leading={
|
||||
editing ? (
|
||||
<span className="flex h-full md:hidden">
|
||||
<BackButton
|
||||
onClick={() =>
|
||||
void guarded(
|
||||
editing.kind === "credential" ? backFromCredential : close,
|
||||
)()
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{editing?.kind === "host" && (
|
||||
<label className="flex items-center gap-2 px-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("manage.advanced")}
|
||||
</span>
|
||||
<FakeSwitch checked={advanced} onChange={setAdvanced} />
|
||||
</label>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => void deleteCurrent()}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<ManageList
|
||||
editing={!!editing}
|
||||
mode={mode}
|
||||
onMode={(next) =>
|
||||
void guarded(() => {
|
||||
setMode(next);
|
||||
close();
|
||||
})()
|
||||
}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
canEditInstanceDefaults={has("admin.settings.manage")}
|
||||
isOnline={isOnline}
|
||||
selectedKey={selectedKey}
|
||||
onPickHost={(host) => void guarded(() => openHost(host))()}
|
||||
onPickCredential={(cred) =>
|
||||
void guarded(() => void openCredential(cred))()
|
||||
}
|
||||
onPickDefaults={(row) => void guarded(() => void openDefaults(row))()}
|
||||
onAdd={() =>
|
||||
void guarded(() =>
|
||||
mode === "credentials"
|
||||
? void openCredential(null)
|
||||
: openHost(null),
|
||||
)()
|
||||
}
|
||||
/>
|
||||
|
||||
{editing ? (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{renderEditor()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden min-h-0 flex-1 items-center justify-center md:flex">
|
||||
<EmptyState
|
||||
icon={mode === "credentials" ? KeyRound : Server}
|
||||
title={
|
||||
mode === "hosts"
|
||||
? t("manage.pickHost")
|
||||
: mode === "credentials"
|
||||
? t("manage.pickCredential")
|
||||
: t("manage.pickDefaults")
|
||||
}
|
||||
hint={t("manage.pickHint")}
|
||||
action={
|
||||
mode !== "defaults" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
mode === "hosts"
|
||||
? openHost(null)
|
||||
: void openCredential(null)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
{mode === "hosts"
|
||||
? t("hosts.addHost")
|
||||
: t("manage.addCredential")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { HostDefaultsLevel } from "@/types/host-defaults";
|
||||
|
||||
export interface DefaultsRow {
|
||||
key: string;
|
||||
level: HostDefaultsLevel;
|
||||
folderName?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** The levels of host defaults a user can open: instance, their own, folders. */
|
||||
export function defaultsRows(
|
||||
hosts: Host[],
|
||||
canEditInstance: boolean,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): DefaultsRow[] {
|
||||
const rows: DefaultsRow[] = [];
|
||||
if (canEditInstance)
|
||||
rows.push({
|
||||
key: "admin",
|
||||
level: "admin",
|
||||
label: t("hostDefaults.titleAdmin"),
|
||||
});
|
||||
rows.push({ key: "user", level: "user", label: t("hostDefaults.titleUser") });
|
||||
const folders = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (!host.folder) continue;
|
||||
let path = "";
|
||||
for (const part of host.folder.split(" / ")) {
|
||||
path = path ? `${path} / ${part}` : part;
|
||||
folders.add(path);
|
||||
}
|
||||
}
|
||||
for (const folder of [...folders].sort((a, b) => a.localeCompare(b))) {
|
||||
rows.push({
|
||||
key: `folder:${folder}`,
|
||||
level: "folder",
|
||||
folderName: folder,
|
||||
label: folder,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData";
|
||||
|
||||
/** Field keys mapped to an i18n key saying what is wrong. */
|
||||
export type HostFormErrors = Partial<Record<"ip" | "sshPort", string>>;
|
||||
|
||||
function validPort(port: unknown): boolean {
|
||||
const n = Number(port);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
}
|
||||
|
||||
/** What has to be fixed before a host can be saved. */
|
||||
export function validateHostForm(
|
||||
form: Pick<HostEditorForm, "ip" | "sshPort">,
|
||||
protocols: Pick<HostProtocols, "enableSsh">,
|
||||
): HostFormErrors {
|
||||
const errors: HostFormErrors = {};
|
||||
if (!form.ip.trim()) errors.ip = "manage.errorAddressRequired";
|
||||
if (protocols.enableSsh && !validPort(form.sshPort))
|
||||
errors.sshPort = "manage.errorPortRange";
|
||||
return errors;
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
|
||||
/**
|
||||
* The Manage list as a tree: folders from the " / " path, sub-hosts under
|
||||
* their parent host.
|
||||
*/
|
||||
|
||||
export type ManageItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** IP for a host, username for a credential. */
|
||||
note: string;
|
||||
/** Extra search text, e.g. tags. */
|
||||
search: string;
|
||||
online?: boolean;
|
||||
children: ManageItem[];
|
||||
};
|
||||
|
||||
export type ManageFolder = {
|
||||
/** Full " / " path, unique across the tree. */
|
||||
path: string;
|
||||
/** Last segment, what the row shows. */
|
||||
name: string;
|
||||
folders: ManageFolder[];
|
||||
items: ManageItem[];
|
||||
};
|
||||
|
||||
export type ManageRow =
|
||||
| {
|
||||
kind: "folder";
|
||||
depth: number;
|
||||
folder: ManageFolder;
|
||||
count: number;
|
||||
online: number;
|
||||
}
|
||||
| { kind: "item"; depth: number; item: ManageItem; folderPath: string };
|
||||
|
||||
function emptyFolder(path: string, name: string): ManageFolder {
|
||||
return { path, name, folders: [], items: [] };
|
||||
}
|
||||
|
||||
/** Walks to the folder at `path`, creating each missing segment on the way. */
|
||||
function folderAt(
|
||||
root: ManageFolder,
|
||||
index: Map<string, ManageFolder>,
|
||||
path: string,
|
||||
) {
|
||||
if (!path) return root;
|
||||
const existing = index.get(path);
|
||||
if (existing) return existing;
|
||||
|
||||
let current = root;
|
||||
let accumulated = "";
|
||||
for (const part of path.split(" / ")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
accumulated = accumulated ? `${accumulated} / ${trimmed}` : trimmed;
|
||||
let next = index.get(accumulated);
|
||||
if (!next) {
|
||||
next = emptyFolder(accumulated, trimmed);
|
||||
index.set(accumulated, next);
|
||||
current.folders.push(next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function buildHostManageTree(
|
||||
hosts: Host[],
|
||||
isOnline: (host: Host) => boolean = (host) => host.online,
|
||||
): ManageFolder {
|
||||
const root = emptyFolder("", "");
|
||||
const index = new Map<string, ManageFolder>();
|
||||
|
||||
const toItem = (host: Host): ManageItem => ({
|
||||
id: host.id,
|
||||
name: host.name || host.ip,
|
||||
note: host.ip,
|
||||
search: `${host.username} ${(host.tags ?? []).join(" ")}`,
|
||||
online: isOnline(host),
|
||||
children: [],
|
||||
});
|
||||
|
||||
const items = new Map(hosts.map((h) => [h.id, toItem(h)]));
|
||||
|
||||
// A parent that is gone or would close a loop drops the host back to its
|
||||
// folder, the same as buildHostTree.
|
||||
const byId = new Map(hosts.map((h) => [h.id, h]));
|
||||
const isDescendant = (candidate: string, ancestor: string) => {
|
||||
const seen = new Set<string>();
|
||||
let current: string | null | undefined = candidate;
|
||||
while (current) {
|
||||
if (current === ancestor) return true;
|
||||
if (seen.has(current)) return false;
|
||||
seen.add(current);
|
||||
current = byId.get(current)?.parentHostId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const nested = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (!host.parentHostId) continue;
|
||||
const parent = items.get(host.parentHostId);
|
||||
if (!parent) continue;
|
||||
if (isDescendant(host.parentHostId, host.id)) continue;
|
||||
parent.children.push(items.get(host.id)!);
|
||||
nested.add(host.id);
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
if (nested.has(host.id)) continue;
|
||||
folderAt(root, index, host.folder ?? "").items.push(items.get(host.id)!);
|
||||
}
|
||||
return sortFolder(root);
|
||||
}
|
||||
|
||||
export function buildCredentialManageTree(
|
||||
credentials: Credential[],
|
||||
): ManageFolder {
|
||||
const root = emptyFolder("", "");
|
||||
const index = new Map<string, ManageFolder>();
|
||||
|
||||
for (const cred of credentials) {
|
||||
folderAt(root, index, cred.folder ?? "").items.push({
|
||||
id: cred.id,
|
||||
name: cred.name,
|
||||
note: cred.username,
|
||||
search: `${cred.type} ${(cred.tags ?? []).join(" ")}`,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
return sortFolder(root);
|
||||
}
|
||||
|
||||
function sortFolder(folder: ManageFolder): ManageFolder {
|
||||
folder.folders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
folder.items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
folder.folders.forEach(sortFolder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
function itemMatches(item: ManageItem, needle: string): boolean {
|
||||
if (`${item.name} ${item.note} ${item.search}`.toLowerCase().includes(needle))
|
||||
return true;
|
||||
return item.children.some((child) => itemMatches(child, needle));
|
||||
}
|
||||
|
||||
/** Total items in a folder and its subfolders, plus how many are online. */
|
||||
export function folderCounts(folder: ManageFolder): {
|
||||
count: number;
|
||||
online: number;
|
||||
} {
|
||||
let count = 0;
|
||||
let online = 0;
|
||||
const walk = (item: ManageItem) => {
|
||||
count++;
|
||||
if (item.online) online++;
|
||||
item.children.forEach(walk);
|
||||
};
|
||||
folder.items.forEach(walk);
|
||||
for (const child of folder.folders) {
|
||||
const sub = folderCounts(child);
|
||||
count += sub.count;
|
||||
online += sub.online;
|
||||
}
|
||||
return { count, online };
|
||||
}
|
||||
|
||||
/** The rows actually drawn. While searching, folders holding a match open on their own. */
|
||||
export function flattenTree(
|
||||
folder: ManageFolder,
|
||||
open: Set<string>,
|
||||
needle: string,
|
||||
depth = 0,
|
||||
rows: ManageRow[] = [],
|
||||
): ManageRow[] {
|
||||
for (const child of folder.folders) {
|
||||
const counts = folderCounts(child);
|
||||
const matching = filterFolder(child, needle);
|
||||
if (!matching) continue;
|
||||
rows.push({
|
||||
kind: "folder",
|
||||
depth,
|
||||
folder: child,
|
||||
count: counts.count,
|
||||
online: counts.online,
|
||||
});
|
||||
if (needle || open.has(child.path))
|
||||
flattenTree(matching, open, needle, depth + 1, rows);
|
||||
}
|
||||
|
||||
for (const item of folder.items) {
|
||||
if (needle && !itemMatches(item, needle)) continue;
|
||||
pushItem(item, folder.path, depth, open, needle, rows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function pushItem(
|
||||
item: ManageItem,
|
||||
folderPath: string,
|
||||
depth: number,
|
||||
open: Set<string>,
|
||||
needle: string,
|
||||
rows: ManageRow[],
|
||||
) {
|
||||
rows.push({ kind: "item", depth, item, folderPath });
|
||||
if (item.children.length === 0) return;
|
||||
if (!needle && !open.has(item.id)) return;
|
||||
for (const child of item.children) {
|
||||
if (needle && !itemMatches(child, needle)) continue;
|
||||
pushItem(child, folderPath, depth + 1, open, needle, rows);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops folders with nothing matching, so search never leaves empty headers. */
|
||||
function filterFolder(
|
||||
folder: ManageFolder,
|
||||
needle: string,
|
||||
): ManageFolder | null {
|
||||
if (!needle) return folder;
|
||||
const folders = folder.folders
|
||||
.map((child) => filterFolder(child, needle))
|
||||
.filter((child): child is ManageFolder => child !== null);
|
||||
const items = folder.items.filter((item) => itemMatches(item, needle));
|
||||
if (folders.length === 0 && items.length === 0) return null;
|
||||
return { ...folder, folders, items };
|
||||
}
|
||||
|
||||
/** Every folder path in the tree, for expand-all and the default open set. */
|
||||
export function allFolderPaths(
|
||||
folder: ManageFolder,
|
||||
out: string[] = [],
|
||||
): string[] {
|
||||
for (const child of folder.folders) {
|
||||
out.push(child.path);
|
||||
allFolderPaths(child, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every parent host id, so sub-hosts can start expanded too. */
|
||||
export function allParentIds(
|
||||
folder: ManageFolder,
|
||||
out: string[] = [],
|
||||
): string[] {
|
||||
const walk = (item: ManageItem) => {
|
||||
if (item.children.length > 0) out.push(item.id);
|
||||
item.children.forEach(walk);
|
||||
};
|
||||
folder.items.forEach(walk);
|
||||
folder.folders.forEach((child) => allParentIds(child, out));
|
||||
return out;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { TITLE_BAR_OFFSET } from "@/shell/TitleBar";
|
||||
import {
|
||||
getOnboardingPlugins,
|
||||
type OnboardingPluginList,
|
||||
@@ -236,6 +237,7 @@ export function OnboardingStage({
|
||||
<OnboardingStageContext.Provider value={stageValue}>
|
||||
<div
|
||||
className="fixed inset-0 z-[60] overflow-y-auto bg-background text-foreground"
|
||||
style={{ paddingTop: TITLE_BAR_OFFSET }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("onboarding.stageLabel")}
|
||||
|
||||
@@ -20,12 +20,22 @@ export function isStepRelevant(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A step that took over older keys counts those as seen at version 1. */
|
||||
function effectiveSeen(
|
||||
step: OnboardingStepDef,
|
||||
seen: Record<string, number>,
|
||||
): number {
|
||||
const own = seen[step.id] ?? 0;
|
||||
if (own > 0 || !step.replaces) return own;
|
||||
return step.replaces.some((key) => (seen[key] ?? 0) > 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
export function isStepPending(
|
||||
step: OnboardingStepDef,
|
||||
seen: Record<string, number>,
|
||||
ctx: OnboardingCtx,
|
||||
): boolean {
|
||||
if ((seen[step.id] ?? 0) < step.version) return true;
|
||||
if (effectiveSeen(step, seen) < step.version) return true;
|
||||
try {
|
||||
return step.forcePending?.(ctx) === true;
|
||||
} catch {
|
||||
|
||||
@@ -42,22 +42,33 @@ export interface OnboardingStepDef {
|
||||
forcePending?: (ctx: OnboardingCtx) => boolean;
|
||||
/** Skip stays hidden until this step has been confirmed. */
|
||||
required?: boolean;
|
||||
/** Older step keys this one took over; seeing one counts as seeing this. */
|
||||
replaces?: string[];
|
||||
}
|
||||
|
||||
/** Where each section of plugin steps sits among the core steps. */
|
||||
export const SECTION_POSITION: Record<OnboardingSection, number> = {
|
||||
start: 25,
|
||||
setup: 50,
|
||||
explore: 80,
|
||||
security: 95,
|
||||
};
|
||||
|
||||
/** An explicit position stays inside the flow, before the last core step. */
|
||||
const MIN_POSITION = 1;
|
||||
const MAX_POSITION = 99;
|
||||
|
||||
export const onboardingSteps = createRegistry<OnboardingStepDef>();
|
||||
|
||||
/** Plugin steps sort inside their section by order, then key. */
|
||||
export function sectionPosition(
|
||||
section: OnboardingSection | undefined,
|
||||
order = 0,
|
||||
position?: number,
|
||||
): number {
|
||||
if (typeof position === "number" && Number.isFinite(position)) {
|
||||
return Math.max(MIN_POSITION, Math.min(MAX_POSITION, position));
|
||||
}
|
||||
const base = SECTION_POSITION[section ?? "explore"];
|
||||
return base + Math.max(-1, Math.min(1, order / 1000)) * 0.4;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import {
|
||||
LayoutTemplate,
|
||||
Monitor,
|
||||
MonitorDown,
|
||||
Palette,
|
||||
Puzzle,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { MonitorDown, Puzzle, Shield } from "lucide-react";
|
||||
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
|
||||
import { PluginsStep } from "./steps/PluginsStep";
|
||||
import { PresetStep } from "./steps/PresetStep";
|
||||
import { AppearanceStep } from "./steps/AppearanceStep";
|
||||
import { SecurityStep } from "./steps/SecurityStep";
|
||||
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
|
||||
import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep";
|
||||
import { desktopUpdates } from "@/lib/desktop-updates";
|
||||
import type { OnboardingStepDef } from "./onboarding-registry";
|
||||
@@ -37,16 +27,6 @@ export const CORE_STEPS: OnboardingStepDef[] = [
|
||||
isRelevant: (ctx) => !ctx.pluginsManagedElsewhere && ctx.pluginSetupPending,
|
||||
forcePending: (ctx) => ctx.pluginSetupPending,
|
||||
},
|
||||
{
|
||||
id: "desktop-sync",
|
||||
version: CORE_ONBOARDING_STEPS["desktop-sync"],
|
||||
titleKey: "onboarding.desktopTitle",
|
||||
icon: Monitor,
|
||||
Component: DesktopSyncStep,
|
||||
audience: "all",
|
||||
position: 20,
|
||||
isRelevant: (ctx) => ctx.isDesktop,
|
||||
},
|
||||
{
|
||||
id: "desktop-updates",
|
||||
version: CORE_ONBOARDING_STEPS["desktop-updates"],
|
||||
@@ -58,24 +38,6 @@ export const CORE_STEPS: OnboardingStepDef[] = [
|
||||
// App Store builds have no updater.
|
||||
isRelevant: (ctx) => ctx.isDesktop && desktopUpdates() !== null,
|
||||
},
|
||||
{
|
||||
id: "preset",
|
||||
version: CORE_ONBOARDING_STEPS.preset,
|
||||
titleKey: "onboarding.presetTitle",
|
||||
icon: LayoutTemplate,
|
||||
Component: PresetStep,
|
||||
audience: "all",
|
||||
position: 30,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
version: CORE_ONBOARDING_STEPS.appearance,
|
||||
titleKey: "onboarding.appearanceTitle",
|
||||
icon: Palette,
|
||||
Component: AppearanceStep,
|
||||
audience: "all",
|
||||
position: 40,
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
version: CORE_ONBOARDING_STEPS.security,
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AccentPicker,
|
||||
FontSizePicker,
|
||||
LanguageSelect,
|
||||
ThemeGrid,
|
||||
UiFontSelect,
|
||||
useAppearanceSettings,
|
||||
} from "@/settings/appearance/appearance-controls";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
{hint && (
|
||||
<span className="text-[10px] text-muted-foreground">{hint}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every change applies right away, so the page itself is the preview. */
|
||||
export function AppearanceStep() {
|
||||
const { t } = useTranslation();
|
||||
const appearance = useAppearanceSettings();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.appearanceIntro")}
|
||||
</p>
|
||||
|
||||
<Field label={t("newUi.sidebar.userProfile.themeLabel")}>
|
||||
<ThemeGrid value={appearance.theme} onChange={appearance.setTheme} />
|
||||
</Field>
|
||||
|
||||
<Field label={t("newUi.sidebar.userProfile.accentColorLabel")}>
|
||||
<AccentPicker
|
||||
value={appearance.accent}
|
||||
onChange={appearance.setAccent}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t("newUi.sidebar.userProfile.interfaceFontLabel")}
|
||||
hint={t("newUi.sidebar.userProfile.interfaceFontDescription")}
|
||||
>
|
||||
<UiFontSelect
|
||||
value={appearance.uiFont}
|
||||
onChange={appearance.setUiFont}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("newUi.sidebar.userProfile.fontSizeLabel")}>
|
||||
<FontSizePicker
|
||||
value={appearance.fontSize}
|
||||
onChange={appearance.setFontSize}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t("newUi.sidebar.userProfile.languageLabel")}>
|
||||
<LanguageSelect
|
||||
value={appearance.language}
|
||||
onChange={appearance.setLanguage}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cloud, Laptop } from "lucide-react";
|
||||
import { LinkServerDialog } from "@/settings/sync/LinkServerDialog";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import { setSyncStatus } from "@/lib/sync-status";
|
||||
import { notifySyncChanged } from "@/lib/linked-server";
|
||||
|
||||
/**
|
||||
* Desktop only: use Termix on this device alone, or link it to a server.
|
||||
* Either choice can be changed later from Sync.
|
||||
*/
|
||||
export function DesktopSyncStep() {
|
||||
const { t } = useTranslation();
|
||||
const status = useSyncStatus();
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const linked = !!status?.linked;
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: "local",
|
||||
icon: Laptop,
|
||||
title: t("onboarding.desktopLocalTitle"),
|
||||
body: t("onboarding.desktopLocalBody"),
|
||||
selected: !linked,
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: "link",
|
||||
icon: Cloud,
|
||||
title: t("onboarding.desktopLinkTitle"),
|
||||
body: linked
|
||||
? t("onboarding.desktopLinked", {
|
||||
server: status?.serverName || status?.serverUrl,
|
||||
})
|
||||
: t("onboarding.desktopLinkBody"),
|
||||
selected: linked,
|
||||
onClick: () => !linked && setLinkOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.desktopBody")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{options.map(({ id, icon: Icon, title, body, selected, onClick }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-start gap-2.5 border p-2.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border bg-card hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">{title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{body}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<LinkServerDialog
|
||||
open={linkOpen}
|
||||
onOpenChange={setLinkOpen}
|
||||
onLinked={(next) => {
|
||||
setSyncStatus(next);
|
||||
notifySyncChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Gauge, Sparkles, Wrench } from "lucide-react";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||
import { PRESETS, type UiPreset } from "@/types/ui-preferences";
|
||||
|
||||
const CHOICES: Exclude<UiPreset, "custom">[] = [
|
||||
"simple",
|
||||
"balanced",
|
||||
"advanced",
|
||||
];
|
||||
|
||||
const ICONS = { simple: Sparkles, balanced: Gauge, advanced: Wrench } as const;
|
||||
|
||||
/**
|
||||
* A miniature of the host list at the given preset. Deliberately drawn rather
|
||||
* than rendering the real component: the point is to show the shape difference
|
||||
* (how many rows fit, how much detail each carries) at a glance.
|
||||
*/
|
||||
function HostRowPreview({ preset }: { preset: Exclude<UiPreset, "custom"> }) {
|
||||
const { hostList } = PRESETS[preset];
|
||||
const rows = hostList.density === "compact" ? 5 : 3;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[3px] border border-border/60 bg-background/60 p-1.5">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex items-stretch gap-1">
|
||||
{hostList.showStatusStripes && (
|
||||
<div className="w-[2px] shrink-0 bg-accent-brand/70" />
|
||||
)}
|
||||
<div className="flex flex-1 flex-col gap-[2px] py-[2px]">
|
||||
<div className="h-[3px] w-2/3 bg-muted-foreground/40" />
|
||||
{hostList.density === "comfortable" && (
|
||||
<div className="h-[2px] w-1/2 bg-muted-foreground/20" />
|
||||
)}
|
||||
{hostList.showTags && (
|
||||
<div className="flex gap-[2px]">
|
||||
<div className="h-[3px] w-3 bg-muted-foreground/20" />
|
||||
<div className="h-[3px] w-2 bg-muted-foreground/20" />
|
||||
</div>
|
||||
)}
|
||||
{hostList.showResourceBars && (
|
||||
<div className="flex gap-[2px]">
|
||||
<div className="h-[2px] w-4 bg-accent-brand/40" />
|
||||
<div className="h-[2px] w-3 bg-accent-brand/25" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PresetStep() {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useUiPreferencesContext();
|
||||
const selected = ctx?.preferences.preset ?? "balanced";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.presetIntro")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{CHOICES.map((preset) => {
|
||||
const Icon = ICONS[preset];
|
||||
const active = selected === preset;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
ctx?.setPreset(preset);
|
||||
ctx?.clearAllOverrides();
|
||||
void applyPresetSideEffects(preset);
|
||||
}}
|
||||
className={`flex flex-col gap-2 border p-2.5 text-left transition-colors ${
|
||||
active
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border bg-card hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold">
|
||||
<Icon
|
||||
size={13}
|
||||
className={
|
||||
active ? "text-accent-brand" : "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
{t(`newUi.sidebar.userProfile.preset_${preset}`)}
|
||||
</span>
|
||||
<HostRowPreview preset={preset} />
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t(`newUi.sidebar.userProfile.preset_${preset}_desc`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{t("onboarding.presetChangeLater")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
interface Bounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface EmbeddedViewApi {
|
||||
open: (options: {
|
||||
id: string;
|
||||
url: string;
|
||||
ignoreCert?: boolean;
|
||||
bounds: Bounds;
|
||||
visible: boolean;
|
||||
}) => Promise<unknown>;
|
||||
setBounds: (id: string, bounds: Bounds) => Promise<unknown>;
|
||||
setVisible: (id: string, visible: boolean) => Promise<unknown>;
|
||||
close: (id: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function embeddedViewApi(): EmbeddedViewApi | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const api = (
|
||||
window as unknown as { electronAPI?: { embeddedView?: unknown } }
|
||||
).electronAPI?.embeddedView;
|
||||
return (api as EmbeddedViewApi | undefined) ?? null;
|
||||
}
|
||||
|
||||
const OVERLAY = [
|
||||
'[role="dialog"]',
|
||||
'[role="alertdialog"]',
|
||||
'[role="menu"]',
|
||||
'[role="listbox"]',
|
||||
"[data-radix-popper-content-wrapper]",
|
||||
].join(",");
|
||||
|
||||
// A native view is drawn above the page, so anything the page opens over it
|
||||
// has to hide it first.
|
||||
function overlayOpen(host: HTMLElement): boolean {
|
||||
return Array.from(document.querySelectorAll(OVERLAY)).some(
|
||||
(node) => !host.contains(node),
|
||||
);
|
||||
}
|
||||
|
||||
export interface NativeViewProps {
|
||||
url: string;
|
||||
/** Accept an invalid TLS certificate from this URL's origin. */
|
||||
ignoreCert?: boolean;
|
||||
/** Hide the view, for example while the tab is not shown. */
|
||||
hidden?: boolean;
|
||||
/** Shown where the desktop app's native view is not available. */
|
||||
fallback?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page shown in a native view (desktop app only), for sites that do not
|
||||
* work in a sandboxed iframe. It fills this element, hides itself when the
|
||||
* element is not visible or a dialog or menu is open, and is closed on
|
||||
* unmount. In a browser it renders `fallback`.
|
||||
*/
|
||||
export function NativeView({
|
||||
url,
|
||||
ignoreCert,
|
||||
hidden,
|
||||
fallback,
|
||||
className,
|
||||
style,
|
||||
}: NativeViewProps) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const api = embeddedViewApi();
|
||||
const hiddenRef = useRef(!!hidden);
|
||||
hiddenRef.current = !!hidden;
|
||||
const sync = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
const element = host.current;
|
||||
const view = embeddedViewApi();
|
||||
if (!element || !view) return;
|
||||
const id = crypto.randomUUID();
|
||||
let opened = false;
|
||||
let lastBounds = "";
|
||||
let lastVisible: boolean | null = null;
|
||||
let frame = 0;
|
||||
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const bounds = {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
const visible =
|
||||
!hiddenRef.current &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
element.offsetParent !== null &&
|
||||
!overlayOpen(element);
|
||||
if (!opened) {
|
||||
opened = true;
|
||||
lastBounds = JSON.stringify(bounds);
|
||||
lastVisible = visible;
|
||||
void view.open({ id, url, ignoreCert, bounds, visible });
|
||||
return;
|
||||
}
|
||||
const key = JSON.stringify(bounds);
|
||||
if (key !== lastBounds) {
|
||||
lastBounds = key;
|
||||
void view.setBounds(id, bounds);
|
||||
}
|
||||
if (visible !== lastVisible) {
|
||||
lastVisible = visible;
|
||||
void view.setVisible(id, visible);
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
};
|
||||
sync.current = schedule;
|
||||
|
||||
const resize = new ResizeObserver(schedule);
|
||||
resize.observe(element);
|
||||
const mutations = new MutationObserver(schedule);
|
||||
mutations.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "hidden", "data-state"],
|
||||
});
|
||||
window.addEventListener("resize", schedule);
|
||||
window.addEventListener("scroll", schedule, true);
|
||||
const timer = window.setInterval(schedule, 500);
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
sync.current = () => {};
|
||||
resize.disconnect();
|
||||
mutations.disconnect();
|
||||
window.removeEventListener("resize", schedule);
|
||||
window.removeEventListener("scroll", schedule, true);
|
||||
window.clearInterval(timer);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
void view.close(id);
|
||||
};
|
||||
}, [url, ignoreCert]);
|
||||
|
||||
useEffect(() => {
|
||||
sync.current();
|
||||
}, [hidden]);
|
||||
|
||||
if (!api) return <>{fallback ?? null}</>;
|
||||
return <div ref={host} className={className} style={style} />;
|
||||
}
|
||||
+113
-4
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, type ComponentType } from "react";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
import type {
|
||||
Disposer,
|
||||
PluginManifest,
|
||||
@@ -10,6 +11,13 @@ import {
|
||||
} from "@/api/plugins-api";
|
||||
import { registerRailItem } from "@/sidebar/rail-items";
|
||||
import { registerTabType, type TabRenderProps } from "@/shell/tab-registry";
|
||||
import {
|
||||
registerCompanionDef,
|
||||
registerCompanionLayoutDef,
|
||||
toggleCompanion,
|
||||
type CompanionDefaults,
|
||||
type CompanionRenderProps,
|
||||
} from "@/shell/companions";
|
||||
import { registerPanel, type PanelRenderProps } from "@/shell/panel-registry";
|
||||
import {
|
||||
registerHostEditorSection,
|
||||
@@ -18,6 +26,8 @@ import {
|
||||
import {
|
||||
registerHostAction,
|
||||
registerHostBadge,
|
||||
registerHostEditorToolbarItem,
|
||||
registerHostStatusIndicator,
|
||||
registerHostContextMenuItem,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import {
|
||||
@@ -64,7 +74,13 @@ import {
|
||||
import { shell, shellHost, tabsApi } from "./shell-bridge";
|
||||
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
|
||||
import {
|
||||
withPluginContext,
|
||||
withPluginScope,
|
||||
withIconBoundary,
|
||||
guardCallback,
|
||||
} from "./scope";
|
||||
import { registerLayout as registerLayoutComponent } from "@/look/layout-registry";
|
||||
import {
|
||||
scopeHostArgs,
|
||||
scopeHostCallback,
|
||||
@@ -157,7 +173,9 @@ export function createPluginApp(
|
||||
? "contributes.tabs"
|
||||
: kind === "panel"
|
||||
? "contributes.panels or contributes.tabs"
|
||||
: "contributes.dashboardCards";
|
||||
: kind === "layout"
|
||||
? "contributes.layouts"
|
||||
: "contributes.dashboardCards";
|
||||
throw new Error(
|
||||
`${pluginId}: ${what} "${id}" is not declared in manifest ${field}`,
|
||||
);
|
||||
@@ -215,6 +233,56 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
registerCompanion(id, component, options = {}) {
|
||||
const useDefaults = options.useDefaults;
|
||||
const Defaults = useDefaults
|
||||
? scoped(function CompanionDefaultsReader({
|
||||
onValue,
|
||||
}: {
|
||||
onValue: (value: CompanionDefaults) => void;
|
||||
}) {
|
||||
const value = useDefaults();
|
||||
const { open, side, size, edgeButton } = value;
|
||||
useEffect(() => {
|
||||
onValue({ open, side, size, edgeButton });
|
||||
}, [onValue, open, side, size, edgeButton]);
|
||||
return null;
|
||||
})
|
||||
: undefined;
|
||||
return track(
|
||||
registerCompanionDef({
|
||||
id: `${pluginId}.${id}`,
|
||||
pluginId,
|
||||
component: scoped(
|
||||
component as unknown as ComponentType<CompanionRenderProps>,
|
||||
),
|
||||
icon: withIconBoundary(pluginId, options.icon as never) as never,
|
||||
titleKey: options.titleKey ? key(options.titleKey) : undefined,
|
||||
order: options.order,
|
||||
tabTypes: options.tabTypes,
|
||||
when: options.when as never,
|
||||
Defaults,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
registerCompanionLayout(tabType, resolve) {
|
||||
return track(
|
||||
registerCompanionLayoutDef({
|
||||
id: `${pluginId}.${tabType}`,
|
||||
tabTypes: [tabType],
|
||||
resolve: resolve as never,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
toggleCompanion(tab, id) {
|
||||
toggleCompanion(
|
||||
tab as unknown as Tab,
|
||||
id ? `${pluginId}.${id}` : undefined,
|
||||
);
|
||||
},
|
||||
|
||||
registerTab(type, component, options = {}) {
|
||||
requireDeclared("tab", type, "tab type");
|
||||
return track(
|
||||
@@ -327,6 +395,31 @@ export function createPluginApp(
|
||||
pluginId,
|
||||
when: hostCallback(badge.when) as never,
|
||||
component: scoped(badge.component) as never,
|
||||
order: badge.order,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
registerHostStatusIndicator(indicator) {
|
||||
return track(
|
||||
registerHostStatusIndicator({
|
||||
id: indicator.id,
|
||||
pluginId,
|
||||
when: hostCallback(indicator.when) as never,
|
||||
component: scoped(indicator.component) as never,
|
||||
order: indicator.order,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
registerHostEditorToolbarItem(item) {
|
||||
return track(
|
||||
registerHostEditorToolbarItem({
|
||||
id: item.id,
|
||||
pluginId,
|
||||
when: hostCallback(item.when) as never,
|
||||
component: scoped(item.component) as never,
|
||||
order: item.order,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -517,7 +610,8 @@ export function createPluginApp(
|
||||
permission: step.permission
|
||||
? resolvePluginPermission(pluginId, step.permission)
|
||||
: undefined,
|
||||
position: sectionPosition(step.section, step.order),
|
||||
position: sectionPosition(step.section, step.order, step.position),
|
||||
replaces: step.replaces,
|
||||
isRelevant: when
|
||||
? (ctx) =>
|
||||
when({
|
||||
@@ -578,6 +672,21 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
registerLayout(layout) {
|
||||
requireDeclared("layout", layout.id, "layout");
|
||||
return track(
|
||||
registerLayoutComponent({
|
||||
id: layout.id,
|
||||
pluginId,
|
||||
component: withPluginContext(pluginId, layout.component),
|
||||
mobileComponent: layout.mobileComponent
|
||||
? withPluginContext(pluginId, layout.mobileComponent)
|
||||
: undefined,
|
||||
defaultTheme: layout.defaultTheme,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
t: ((key: string, options?: Record<string, unknown> | string) =>
|
||||
i18n.t(
|
||||
pluginKey(pluginId, key),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readStatusColorScheme } from "@/hooks/use-status-color-scheme";
|
||||
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
||||
import { useHostActions } from "@/sidebar/host-contributions";
|
||||
import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry";
|
||||
import { useCompanionDefs } from "@/shell/companions";
|
||||
import { getExtension, useExtensions } from "./extension-registry";
|
||||
import { useTranslation as useI18nTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -48,6 +49,8 @@ import { invokeAction } from "@/shell/action-registry";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { usePluginComponent } from "./component-registry";
|
||||
import { themeMode, useThemes } from "@/look/theme-registry";
|
||||
import { usePluginShell } from "@/shell/controller/plugin-shell";
|
||||
import {
|
||||
getLiveHostStatus,
|
||||
useOptionalHostStatusEntry,
|
||||
@@ -337,15 +340,13 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
|
||||
useTheme() {
|
||||
const { theme } = useTheme();
|
||||
if (theme === "light") return { theme: "light" };
|
||||
if ((theme as string) === "system" && typeof window !== "undefined") {
|
||||
return {
|
||||
theme: window.matchMedia?.("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: "dark",
|
||||
};
|
||||
}
|
||||
return { theme: "dark" };
|
||||
// Re-render when plugin themes arrive, since they carry their mode.
|
||||
useThemes();
|
||||
return { theme: themeMode(theme) };
|
||||
},
|
||||
|
||||
useShell() {
|
||||
return usePluginShell(usePluginScope());
|
||||
},
|
||||
|
||||
toast: {
|
||||
@@ -401,6 +402,17 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
|
||||
useHostActions: () => useHostActions() as unknown as HostActionContribution[],
|
||||
|
||||
useCompanions: (tabType) => {
|
||||
const defs = useCompanionDefs();
|
||||
return defs
|
||||
.filter((def) => !def.tabTypes?.length || def.tabTypes.includes(tabType))
|
||||
.map((def) => ({
|
||||
id: def.id,
|
||||
titleKey: def.titleKey ?? def.id,
|
||||
icon: def.icon as never,
|
||||
}));
|
||||
},
|
||||
|
||||
useActivityTypes: () => {
|
||||
const defs = useTabTypes();
|
||||
const types = new Set<string>();
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
setPluginSummaries,
|
||||
} from "./plugin-store";
|
||||
import { syncHostFeatureTabs } from "@/settings/host-feature-tabs";
|
||||
import {
|
||||
chosenLayoutId,
|
||||
loadLook,
|
||||
safeLayoutRequested,
|
||||
} from "@/look/look-store";
|
||||
import { BUILTIN_THEMES, setPluginThemes } from "@/look/theme-registry";
|
||||
import { setPluginLayouts } from "@/look/layout-registry";
|
||||
|
||||
/**
|
||||
* Loads plugin frontends into the running shell.
|
||||
@@ -55,14 +62,22 @@ let queue: Promise<void> = Promise.resolve();
|
||||
let deps: PluginLoaderDeps | null = null;
|
||||
let started = false;
|
||||
let guestMode = false;
|
||||
/** Signed in with the full runtime: only the chosen layout's code loads. */
|
||||
let gateLayouts = false;
|
||||
|
||||
function assetUrlWithVersion(
|
||||
pluginId: string,
|
||||
file: string,
|
||||
version: string | null | undefined,
|
||||
): string {
|
||||
const query = version ? `?v=${encodeURIComponent(version)}` : "";
|
||||
return getBackendUrl(
|
||||
`/plugin-assets/${encodeURIComponent(pluginId)}/${file}${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assetUrl(summary: PluginSummary, file: string): string {
|
||||
const version = summary.assetVersion
|
||||
? `?v=${encodeURIComponent(summary.assetVersion)}`
|
||||
: "";
|
||||
return getBackendUrl(
|
||||
`/plugin-assets/${encodeURIComponent(summary.id)}/${file}${version}`,
|
||||
);
|
||||
return assetUrlWithVersion(summary.id, file, summary.assetVersion);
|
||||
}
|
||||
|
||||
/** Absolute URL, so import() resolves it against the page, not this module. */
|
||||
@@ -286,11 +301,75 @@ function namespaceVersion(summary: PluginSummary): string {
|
||||
return `${summary.version}|${summary.assetVersion ?? ""}`;
|
||||
}
|
||||
|
||||
/** Puts every running plugin's themes and every enabled plugin's layouts on offer. */
|
||||
function syncLooks(summaries: PluginSummary[]): void {
|
||||
const fallback = (mode: "light" | "dark") =>
|
||||
BUILTIN_THEMES.find((theme) => theme.id === mode)!.swatch;
|
||||
setPluginThemes(
|
||||
summaries.filter(serverServesAssets).flatMap((summary) =>
|
||||
summary.themeVersion
|
||||
? (summary.contributes?.themes ?? []).map((theme) => ({
|
||||
id: theme.id,
|
||||
label: theme.label,
|
||||
mode: theme.mode,
|
||||
swatch: theme.swatch ?? fallback(theme.mode),
|
||||
pluginId: summary.id,
|
||||
cssHref: assetUrlWithVersion(
|
||||
summary.id,
|
||||
`_themes/${theme.id}.css`,
|
||||
summary.themeVersion,
|
||||
),
|
||||
}))
|
||||
: [],
|
||||
),
|
||||
);
|
||||
setPluginLayouts(
|
||||
summaries
|
||||
.filter((summary) => summary.enabled)
|
||||
.flatMap((summary) =>
|
||||
(summary.contributes?.layouts ?? []).map((layout) => ({
|
||||
id: layout.id,
|
||||
label: layout.label,
|
||||
description: layout.description,
|
||||
defaultTheme: layout.defaultTheme,
|
||||
pluginId: summary.id,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout plugin's code runs only while one of its layouts is the one
|
||||
* shown, and never in the repair view, so a broken layout cannot break the
|
||||
* way out of it.
|
||||
*/
|
||||
async function withoutUnchosenLayouts(
|
||||
summaries: PluginSummary[],
|
||||
): Promise<PluginSummary[]> {
|
||||
if (!gateLayouts) return summaries;
|
||||
await loadLook();
|
||||
const chosen = safeLayoutRequested() ? null : chosenLayoutId();
|
||||
return summaries.map((summary) => {
|
||||
const layouts = summary.contributes?.layouts ?? [];
|
||||
if (layouts.length === 0) return summary;
|
||||
if (layouts.some((layout) => layout.id === chosen)) return summary;
|
||||
if (
|
||||
summary.frontend &&
|
||||
getPluginRecord(summary.id)?.frontend !== "inactive"
|
||||
)
|
||||
setFrontendState(summary.id, "inactive");
|
||||
return { ...summary, frontend: false };
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
||||
setPluginSummaries(summaries);
|
||||
syncLooks(summaries);
|
||||
await loadNamespaces(summaries.filter(serverServesAssets));
|
||||
|
||||
const { order, blocked } = orderForActivation(summaries);
|
||||
const { order, blocked } = orderForActivation(
|
||||
await withoutUnchosenLayouts(summaries),
|
||||
);
|
||||
const wanted = new Map(order.map((summary) => [summary.id, summary]));
|
||||
|
||||
// Dependents go first, so nothing outlives what it depends on.
|
||||
@@ -393,6 +472,12 @@ export function syncPlugins(summaries?: PluginSummary[]): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Tries a plugin's frontend again even though this bundle failed before. */
|
||||
export function retryPluginFrontend(pluginId: string): Promise<void> {
|
||||
failedVersions.delete(pluginId);
|
||||
return syncPlugins();
|
||||
}
|
||||
|
||||
/** Fired by anything that changes plugin state, e.g. the admin toggle. */
|
||||
const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
||||
|
||||
@@ -503,6 +588,7 @@ export function startPluginRuntime(
|
||||
return syncPlugins();
|
||||
}
|
||||
|
||||
gateLayouts = true;
|
||||
if (!started && typeof window !== "undefined") {
|
||||
started = true;
|
||||
lastFocusSync = Date.now();
|
||||
@@ -514,6 +600,7 @@ export function startPluginRuntime(
|
||||
|
||||
/** Deactivates every plugin, on logout. */
|
||||
export function stopPluginRuntime(): Promise<void> {
|
||||
gateLayouts = false;
|
||||
if (started && typeof window !== "undefined") {
|
||||
started = false;
|
||||
window.removeEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
|
||||
|
||||
@@ -114,6 +114,28 @@ export function withPluginScope<P extends object>(
|
||||
return Scoped;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin id for the hooks without the error boundary, for a component
|
||||
* whose caller handles a crash itself (a layout, which the repair view takes
|
||||
* over from).
|
||||
*/
|
||||
export function withPluginContext<P extends object>(
|
||||
pluginId: string,
|
||||
Inner: ComponentType<P>,
|
||||
): ComponentType<P> {
|
||||
function Scoped(props: P) {
|
||||
return (
|
||||
<PluginScopeContext.Provider value={pluginId}>
|
||||
<Suspense fallback={null}>
|
||||
<Inner {...props} />
|
||||
</Suspense>
|
||||
</PluginScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
Scoped.displayName = `Plugin(${pluginId}:${Inner.displayName ?? Inner.name ?? "Component"})`;
|
||||
return Scoped;
|
||||
}
|
||||
|
||||
/** Renders nothing when a plugin's icon throws, instead of a crash notice. */
|
||||
class IconBoundary extends Component<BoundaryProps, { error: boolean }> {
|
||||
state = { error: false };
|
||||
|
||||
@@ -152,6 +152,9 @@ export { useTabs, useTabsSafe } from "@/shell/TabContext";
|
||||
|
||||
// Slots: a plugin can offer places for other plugins to fill.
|
||||
export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
||||
// For layouts: where tabs show, and the confirm a tab close asks with.
|
||||
export { TabStage } from "@/shell/stage/TabStage";
|
||||
export { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider";
|
||||
|
||||
// A plugin's manifest host settings, drawn inside its own host editor section.
|
||||
export {
|
||||
@@ -350,3 +353,470 @@ export {
|
||||
type ConnectionGateProps,
|
||||
} from "@/components/connection/ConnectionGate";
|
||||
export { type ConnectionUnavailable } from "@/components/connection/ConnectionScreen";
|
||||
|
||||
// For shell-level plugins such as a layout: the API client, shared types and
|
||||
// the shell's own state. These reach into core, so they may change with a core
|
||||
// release. An ordinary plugin should not need them.
|
||||
export { APP_VERSION } from "@/lib/app-version";
|
||||
// The palette's own input has to share the Command it sits in, so cmdk is
|
||||
// the shell's copy.
|
||||
export { Command as CommandPrimitive } from "cmdk";
|
||||
export {
|
||||
type AuthOverrideProtocol,
|
||||
SSH_AUTH_PROTOCOL,
|
||||
} from "@/types/auth-protocols";
|
||||
export {
|
||||
CREDENTIAL_ROW_FIELD_KEYS,
|
||||
type CredentialDensity,
|
||||
type CredentialRowFields,
|
||||
type CredentialSidebarFilterState,
|
||||
type CredentialSidebarPreferences,
|
||||
type CredentialSortKey,
|
||||
type CredentialTrayTrigger,
|
||||
defaultCredentialRowFields,
|
||||
defaultCredentialSidebarPreferences,
|
||||
sanitizeCredentialSidebarPreferences,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
export {
|
||||
CORE_HOST_DEFAULTS,
|
||||
CORE_NAMESPACE,
|
||||
type DefaultOverrides,
|
||||
defaultValuesEqual,
|
||||
findCoreHostDefault,
|
||||
type HostDefaultsLevel,
|
||||
type ResolvedHostDefault,
|
||||
splitDefaultKey,
|
||||
} from "@/types/host-defaults";
|
||||
export {
|
||||
defaultHostRowFields,
|
||||
defaultHostSidebarPreferences,
|
||||
type HostClickBehavior,
|
||||
type HostDensity,
|
||||
type HostGroupKey,
|
||||
type HostRowFields,
|
||||
type HostSidebarFilterState,
|
||||
type HostSidebarPreferences,
|
||||
type HostTrayTrigger,
|
||||
sanitizeHostSidebarPreferences,
|
||||
type SortKey,
|
||||
} from "@/types/host-sidebar-preferences";
|
||||
export type {
|
||||
HostData,
|
||||
HostLock,
|
||||
HostProtocolAuthInput,
|
||||
ProxyNode,
|
||||
SSHHost,
|
||||
SSHHostData,
|
||||
} from "@/types";
|
||||
export {
|
||||
PRESETS,
|
||||
UI_AREA_KEYS,
|
||||
type UiAreaKey,
|
||||
type UiPreset,
|
||||
} from "@/types/ui-preferences";
|
||||
export type {
|
||||
AdminSection,
|
||||
Credential,
|
||||
DashboardCardId,
|
||||
FontSizeId,
|
||||
Host,
|
||||
HostFolder,
|
||||
SharePermissionLevel,
|
||||
Tab,
|
||||
TabType,
|
||||
ThemeId,
|
||||
ToolsTab,
|
||||
UiFontId,
|
||||
} from "@/types/ui-types";
|
||||
export {
|
||||
type AuditLog,
|
||||
type AuditLogFilters,
|
||||
getAuditLogActions,
|
||||
getAuditLogs,
|
||||
} from "@/api/audit-log-api";
|
||||
export {
|
||||
getUserSecondFactors,
|
||||
resetUserSecondFactors,
|
||||
type UserSecondFactorSummary,
|
||||
} from "@/api/auth-methods-api";
|
||||
export { getCredentials } from "@/api/credentials-api";
|
||||
export {
|
||||
getFolderDefaultsId,
|
||||
getHostDefaultsLevel,
|
||||
type HostDefaultsChange,
|
||||
type HostDefaultsTarget,
|
||||
previewHostDefaultsLevel,
|
||||
resetHostToDefaults,
|
||||
resolveHostDefaults,
|
||||
saveHostDefaultsLevel,
|
||||
waitForDefaultsJob,
|
||||
} from "@/api/host-defaults-api";
|
||||
export { getHostTags, saveHostTags } from "@/api/host-tags-api";
|
||||
export {
|
||||
getSessionTimeoutMinutes,
|
||||
getUserPreferences,
|
||||
parseCustomKeybindings,
|
||||
saveUserPreferences,
|
||||
} from "@/api/open-tabs-api";
|
||||
export {
|
||||
getDeveloperMode,
|
||||
getPluginAdminSettings,
|
||||
getPluginHostSettings,
|
||||
getPlugins,
|
||||
getPluginUserSettings,
|
||||
type PluginSettingsErrors,
|
||||
type PluginSettingsField,
|
||||
PluginSettingsValidationError,
|
||||
type PluginSummary,
|
||||
setAllPluginChannels,
|
||||
setDeveloperMode,
|
||||
setPluginOptions,
|
||||
updatePluginAdminSettings,
|
||||
updatePluginHostSettings,
|
||||
updatePluginUserSettings,
|
||||
} from "@/api/plugins-api";
|
||||
export {
|
||||
getNotificationPrivateEndpoints,
|
||||
setNotificationPrivateEndpoints,
|
||||
} from "@/api/private-endpoints-api";
|
||||
export {
|
||||
type CredentialPermissionLevel,
|
||||
type FolderAccessRule,
|
||||
getCredentialAccess,
|
||||
getFolderAccess,
|
||||
getRoles,
|
||||
revokeCredentialAccess,
|
||||
revokeFolderAccess,
|
||||
shareCredential,
|
||||
type ShareTarget,
|
||||
} from "@/api/rbac-api";
|
||||
export { type BrandingSettings, getBranding } from "@/api/settings-api";
|
||||
export {
|
||||
getChannelReleases,
|
||||
getUpdateChannel,
|
||||
type ReleaseInfo,
|
||||
setUpdateChannel,
|
||||
type UpdateChannelState,
|
||||
} from "@/api/system-status-api";
|
||||
export {
|
||||
getTlsStatus,
|
||||
type TlsStatus,
|
||||
uploadTlsCertificate,
|
||||
} from "@/api/tls-api";
|
||||
export {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/command";
|
||||
export { usePluginDocsUrl } from "@/components/docs-link";
|
||||
export {
|
||||
DEFAULT_FOLDER_COLOR,
|
||||
DEFAULT_FOLDER_ICON,
|
||||
FolderIconEl,
|
||||
HexColorPicker,
|
||||
IconPicker,
|
||||
} from "@/components/folder-style";
|
||||
export { useTheme } from "@/components/theme-provider";
|
||||
export { VersionBadge } from "@/components/version-badge";
|
||||
export { useBranding } from "@/contexts/BrandingContext";
|
||||
export {
|
||||
useAreaPreferences,
|
||||
useUiPreference,
|
||||
useUiPreferencesContext,
|
||||
} from "@/contexts/UiPreferencesContext";
|
||||
export {
|
||||
getRegisteredDashboardCard,
|
||||
useRegisteredDashboardCards,
|
||||
} from "@/dashboard/dashboard-cards-registry";
|
||||
export { useActionSlot } from "@/hooks/use-action-slot";
|
||||
export { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
||||
export { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
export { usePermissions } from "@/hooks/use-permissions";
|
||||
export {
|
||||
getStatusClasses,
|
||||
useStatusColorScheme,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
export { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
export { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
export { LANGUAGES } from "@/i18n/languages";
|
||||
export {
|
||||
getLiveHostStatus,
|
||||
useHostStatus,
|
||||
useServerStatus,
|
||||
useServerStatusMeta,
|
||||
} from "@/lib/ServerStatusContext";
|
||||
export { activityTarget } from "@/lib/activity-types";
|
||||
export { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||
export { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
export {
|
||||
canConfigureUpdates,
|
||||
type DesktopUpdateMode,
|
||||
desktopUpdates,
|
||||
type DesktopUpdateState,
|
||||
useDesktopUpdate,
|
||||
} from "@/lib/desktop-updates";
|
||||
export { type DocsPage, docsUrl } from "@/lib/docs";
|
||||
export { getErrorMessage } from "@/lib/error-message";
|
||||
export {
|
||||
getDefaultConnectionTab,
|
||||
resolveHostTabType,
|
||||
} from "@/lib/host-connection-tabs";
|
||||
export {
|
||||
DefaultsOnly,
|
||||
HostDefaultsContext,
|
||||
type HostDefaultsContextValue,
|
||||
HostOnly,
|
||||
} from "@/lib/host-defaults-context";
|
||||
export {
|
||||
invalidateHostsAndStatusCaches,
|
||||
invalidateServerStatusCache,
|
||||
} from "@/lib/hosts-request-cache";
|
||||
export { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
|
||||
export { notifySyncChanged, useDesktopLinked } from "@/lib/linked-server";
|
||||
export {
|
||||
clearLocalAdaptivePreferences,
|
||||
getPreferredHostAction,
|
||||
recordHostActionPreference,
|
||||
} from "@/lib/local-adaptive-preferences";
|
||||
export { pluginKey, pluginName } from "@/lib/plugin-i18n";
|
||||
export { PluginIcon } from "@/lib/plugin-icon";
|
||||
export { rem } from "@/lib/rem";
|
||||
export { getConnectedRemoteApi } from "@/lib/remote-server-api";
|
||||
export { roleLabel } from "@/lib/role-label";
|
||||
export type { StatusValue } from "@/lib/server-status-store";
|
||||
export { parseServerTime } from "@/lib/server-time";
|
||||
export { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
|
||||
export {
|
||||
ACCENT_PRESET_COLORS,
|
||||
applyAccentColor,
|
||||
applyFontSize,
|
||||
applyUiFont,
|
||||
DASHBOARD_CARDS,
|
||||
FONT_SIZES,
|
||||
UI_FONTS,
|
||||
} from "@/lib/theme";
|
||||
export { registerLayout } from "@/look/layout-registry";
|
||||
export { useChooseTheme } from "@/look/use-look-theme";
|
||||
export {
|
||||
type AccessRecord,
|
||||
type ActiveSessionInfo,
|
||||
adminCreateUser,
|
||||
adminCreateUserCredential,
|
||||
adminCreateUserHost,
|
||||
adminDeleteUserCredential,
|
||||
adminDeleteUserHost,
|
||||
adminExportUserData,
|
||||
adminGetHostPassword,
|
||||
adminGetUserCredentials,
|
||||
adminGetUserHosts,
|
||||
adminResetUserPassword,
|
||||
adminUpdateUserCredential,
|
||||
adminUpdateUserHost,
|
||||
type ApiKey,
|
||||
assignRoleToUser,
|
||||
bulkUpdateSSHHosts,
|
||||
changePassword,
|
||||
createApiKey,
|
||||
createCredential,
|
||||
type CreatedApiKey,
|
||||
createRole,
|
||||
createSSHHost,
|
||||
deleteAccount,
|
||||
deleteAllHostsInFolder,
|
||||
deleteApiKey,
|
||||
deleteCredential,
|
||||
deleteOpenTab,
|
||||
deleteRole,
|
||||
deleteSSHHost,
|
||||
deleteUser,
|
||||
duplicateCredential,
|
||||
getActiveSessions,
|
||||
getApiKeys,
|
||||
getCredentialDetails,
|
||||
getCredentialSidebarPreferences,
|
||||
getExternalAutoProvision,
|
||||
getHostAccess,
|
||||
getHostAuthOverride,
|
||||
getHostPassword,
|
||||
getHostPolicy,
|
||||
type HostPolicy,
|
||||
getHostSidebarPreferences,
|
||||
getHttpsSettings,
|
||||
type HttpsSettings,
|
||||
getLogLevel,
|
||||
getPasswordLoginAllowed,
|
||||
getPasswordResetAllowed,
|
||||
getPermissionsCatalog,
|
||||
getRegistrationAllowed,
|
||||
getSecondFactorAfterExternalLogin,
|
||||
getSessions,
|
||||
getSessionTimeout,
|
||||
getSSHFolders,
|
||||
getSSHHosts,
|
||||
getStatusCheckSettings,
|
||||
getUserInfo,
|
||||
getUserList,
|
||||
getUserRoles,
|
||||
type LinkedAccountInfo,
|
||||
linkExternalToPasswordAccount,
|
||||
logoutUser,
|
||||
makeUserAdmin,
|
||||
type OpenTabRecord,
|
||||
type PermissionCatalogEntry,
|
||||
releaseUrlFrom,
|
||||
removeAdminStatus,
|
||||
removeRoleFromUser,
|
||||
renameCredentialFolder,
|
||||
renameFolder,
|
||||
reorderCredentials,
|
||||
reorderFolders,
|
||||
reorderSSHHosts,
|
||||
resetRecentActivity,
|
||||
revokeAllUserSessions,
|
||||
revokeHostAccess,
|
||||
revokeSession,
|
||||
type Role,
|
||||
saveCredentialSidebarPreferences,
|
||||
saveHostSidebarPreferences,
|
||||
setHostAuthOverride,
|
||||
shareFolder,
|
||||
shareHost,
|
||||
type SSHHostWithStatus,
|
||||
unlinkExternalFromPasswordAccount,
|
||||
updateCredential,
|
||||
updateExternalAutoProvision,
|
||||
updateFolderMetadata,
|
||||
updateHostAccess,
|
||||
updateHttpsSettings,
|
||||
updateLogLevel,
|
||||
updatePasswordLoginAllowed,
|
||||
updatePasswordResetAllowed,
|
||||
updateRegistrationAllowed,
|
||||
updateRole,
|
||||
updateSecondFactorAfterExternalLogin,
|
||||
updateSessionTimeout,
|
||||
updateSSHHost,
|
||||
updateStatusCheckSettings,
|
||||
type UserPreferences,
|
||||
type UserRole,
|
||||
} from "@/main-axios";
|
||||
export {
|
||||
MANAGE_REQUEST_EVENT,
|
||||
type ManageMode,
|
||||
type ManageRequest,
|
||||
requestFromLegacyEvent,
|
||||
requestManage,
|
||||
takePendingManageRequest,
|
||||
} from "@/manage/manage-requests";
|
||||
export { useSshAuthEditors } from "@/plugin-host/auth-registry";
|
||||
export { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
export { shell } from "@/plugin-host/shell-bridge";
|
||||
export { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
export { isPrerelease } from "@/plugins/plugin-model";
|
||||
export { usePluginHostSections } from "@/settings/HostPluginSections";
|
||||
export { SettingsFieldRow } from "@/settings/SettingsFields";
|
||||
export { LookSettings } from "@/look/LookSettings";
|
||||
export { useThemeOptions } from "@/settings/appearance/appearance-controls";
|
||||
export {
|
||||
hasVisibleFields,
|
||||
isFieldShown,
|
||||
} from "@/settings/settings-fields-util";
|
||||
export { NativeView, type NativeViewProps } from "./NativeView";
|
||||
export { useShellController } from "@/shell/controller/ShellControllerProvider";
|
||||
export {
|
||||
type KeybindingActionDef,
|
||||
useKeybindingActions,
|
||||
useKeybindingDefaults,
|
||||
} from "@/shell/keybinding-registry";
|
||||
export {
|
||||
filterPaletteItems,
|
||||
loadPaletteGroup,
|
||||
paletteEntriesFor,
|
||||
type PaletteItemDef,
|
||||
usePaletteEntries,
|
||||
usePaletteGroups,
|
||||
} from "@/shell/palette-registry";
|
||||
export { getPanel, usePanels } from "@/shell/panel-registry";
|
||||
export { SplitLayoutMenu } from "@/shell/split/SplitLayoutMenu";
|
||||
export {
|
||||
beginSplitDrag,
|
||||
endSplitDrag,
|
||||
isSplitDragging,
|
||||
moveSplitDrag,
|
||||
} from "@/shell/split/split-drag";
|
||||
export {
|
||||
canJoinSplit,
|
||||
isSplitTab,
|
||||
type SplitSummary,
|
||||
type TabSplitAction,
|
||||
} from "@/shell/split/split-tabs";
|
||||
export {
|
||||
openInSplit,
|
||||
type SplitOpenTarget,
|
||||
useSplitTargets,
|
||||
} from "@/shell/split/split-targets";
|
||||
export { MAX_PANES } from "@/shell/split/split-tree";
|
||||
export { registerTabCloseGuard } from "@/shell/tab-close-guards";
|
||||
export { useTabHandlesVersion } from "@/shell/tab-handles";
|
||||
export {
|
||||
getTabType,
|
||||
isPersistentTabType,
|
||||
isSessionTabType,
|
||||
type TabShellCallbacks,
|
||||
} from "@/shell/tab-registry";
|
||||
export {
|
||||
markTabSurfaceUsed,
|
||||
preloadTabSurface,
|
||||
tabIcon,
|
||||
} from "@/shell/tabUtils";
|
||||
export { AuthEnrollmentSections } from "@/sidebar/AuthEnrollmentSections";
|
||||
export { mapCredentials, sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
export {
|
||||
getHostEditorSection,
|
||||
makeHostSshSubTabs,
|
||||
makeHostTabs,
|
||||
useHostEditorSections,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
export {
|
||||
actionOrder,
|
||||
defaultConnectAction,
|
||||
type HostActionDef,
|
||||
hostActionsFor,
|
||||
hostBadgesFor,
|
||||
hostEditorToolbarItemsFor,
|
||||
hostMenuItemsFor,
|
||||
hostStatusIndicatorsFor,
|
||||
listHostActions,
|
||||
runHostAction,
|
||||
useHostActions,
|
||||
useHostBadges,
|
||||
useHostContextMenuItems,
|
||||
useHostEditorToolbarItems,
|
||||
useHostStatusIndicators,
|
||||
} from "@/sidebar/host-contributions";
|
||||
export { useEditorDefaultKeys } from "@/sidebar/host-defaults/catalog";
|
||||
export {
|
||||
enabledHostProtocols,
|
||||
type HostProtocolDef,
|
||||
hostProtocolFlags,
|
||||
type HostProtocols,
|
||||
listHostProtocols,
|
||||
protocolEnabled,
|
||||
protocolPort,
|
||||
useHostProtocols,
|
||||
withProtocolSettings,
|
||||
} from "@/sidebar/host-protocols";
|
||||
export { quickConnectTargets } from "@/sidebar/quick-connect-host";
|
||||
export {
|
||||
groupRailItems,
|
||||
isCoreRailView,
|
||||
moveRailItem,
|
||||
promotableIds,
|
||||
type RailGroup,
|
||||
type RailItemDef,
|
||||
railItemLabel,
|
||||
rightDockableIds,
|
||||
useRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
/**
|
||||
* The Termix side of @termix-ssh/plugin-sdk/testing's renderWithApp.
|
||||
*
|
||||
@@ -462,3 +463,43 @@ export async function renderPlugin(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Registries a shell-level plugin's own tests fill directly, since they
|
||||
// render parts of the shell without activating a plugin.
|
||||
export {
|
||||
registerHostAction,
|
||||
resetHostContributions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
export { registerHostProtocol } from "@/sidebar/host-protocols";
|
||||
export {
|
||||
RAIL_ITEMS,
|
||||
registerRailItem,
|
||||
resetRegisteredRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
export {
|
||||
declareActionSlot,
|
||||
registerAction,
|
||||
registerSlotContribution,
|
||||
resetActionRegistry,
|
||||
} from "@/shell/action-registry";
|
||||
export { trackTabHandle } from "@/shell/tab-handles";
|
||||
export {
|
||||
registerKeybindingAction,
|
||||
registerKeybindingDefault,
|
||||
resetKeybindingRegistry,
|
||||
} from "@/shell/keybinding-registry";
|
||||
export { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences";
|
||||
export { HostPluginSections } from "@/settings/HostPluginSections";
|
||||
export {
|
||||
registerDashboardCard,
|
||||
resetDashboardCards,
|
||||
} from "@/dashboard/dashboard-cards-registry";
|
||||
export {
|
||||
resetPluginStore,
|
||||
setPluginSummaries,
|
||||
} from "@/plugin-host/plugin-store";
|
||||
export { setPermissionsForTesting } from "@/hooks/use-permissions";
|
||||
export { getRegisteredLayout } from "@/look/layout-registry";
|
||||
export { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
|
||||
export { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||
export { TabPortals } from "@/shell/stage/TabPortals";
|
||||
@@ -7,7 +7,7 @@ import { getPluginStoreState, type PluginRecord } from "./plugin-store";
|
||||
* because a disabled plugin never runs its frontend and so never registers
|
||||
* anything, yet a saved tab or card of its still has to say whose it is.
|
||||
*/
|
||||
export type ViewKind = "tab" | "panel" | "card";
|
||||
export type ViewKind = "tab" | "panel" | "card" | "layout";
|
||||
|
||||
export type ViewStatus =
|
||||
/** Registered by a running plugin. */
|
||||
@@ -31,6 +31,9 @@ function declares(record: PluginRecord, kind: ViewKind, id: string): boolean {
|
||||
!!contributes.tabs?.some((tab) => tab.id === id)
|
||||
);
|
||||
}
|
||||
if (kind === "layout") {
|
||||
return !!contributes.layouts?.some((layout) => layout.id === id);
|
||||
}
|
||||
return !!contributes.dashboardCards?.some((card) => card.id === id);
|
||||
}
|
||||
|
||||
|
||||
@@ -184,19 +184,32 @@ export function PluginDetail({
|
||||
? t("plugins.manager.disable")
|
||||
: t("plugins.manager.enable")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busy || locked}
|
||||
onClick={async () => {
|
||||
if (await manager.uninstall(plugin)) onUninstalled();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("plugins.manager.uninstall")}
|
||||
</Button>
|
||||
{!plugin.defaultLook && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busy || locked}
|
||||
onClick={async () => {
|
||||
if (await manager.uninstall(plugin)) onUninstalled();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("plugins.manager.uninstall")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : plugin.unsupported ? (
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title={t("plugins.manager.unsupportedHint")}
|
||||
>
|
||||
{plugin.needsTermix
|
||||
? t("plugins.manager.needsTermix", {
|
||||
range: plugin.needsTermix,
|
||||
})
|
||||
: t("plugins.manager.unsupported")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
+642
-230
File diff suppressed because it is too large.
Load diff
Loaded 100 of 577 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user