mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -1,63 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface SimpleLoaderProps {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function SimpleLoader({
|
||||
visible,
|
||||
message,
|
||||
className,
|
||||
backgroundColor,
|
||||
}: SimpleLoaderProps) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.simple-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-base);
|
||||
border-top-color: var(--foreground);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center z-[100]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="simple-spinner"></div>
|
||||
{message && (
|
||||
<p className="text-sm text-foreground-secondary font-medium">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,15 @@ import {
|
||||
getCredentialSidebarPreferences,
|
||||
saveCredentialSidebarPreferences,
|
||||
} from "@/main-axios";
|
||||
import { PRESETS, type UiPreset } from "@/types/ui-preferences";
|
||||
import {
|
||||
PRESETS,
|
||||
type UiPreset,
|
||||
type UiRailPreferences,
|
||||
} from "@/types/ui-preferences";
|
||||
import {
|
||||
listRegisteredRailItems,
|
||||
type RailItemDef,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { sanitizeHostSidebarPreferences } from "@/types/host-sidebar-preferences";
|
||||
import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar-preferences";
|
||||
import { getRegisteredDashboardCard } from "@/dashboard/dashboard-cards-registry";
|
||||
@@ -68,6 +76,23 @@ function writeLocal(key: string, value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail views a preset hides: its own list, plus every plugin item that
|
||||
* does not ask to stay when the preset hides plugin items.
|
||||
*/
|
||||
export function presetHiddenRailTabs(
|
||||
rail: UiRailPreferences,
|
||||
pluginItems: Pick<RailItemDef, "id" | "simplePreset" | "hideable">[],
|
||||
): string[] {
|
||||
if (!rail.hidePluginItems) return [...rail.hiddenTabs];
|
||||
return [
|
||||
...rail.hiddenTabs,
|
||||
...pluginItems
|
||||
.filter((item) => !item.simplePreset && item.hideable !== false)
|
||||
.map((item) => item.id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a preset's values into the stores that own them. Safe to call when
|
||||
* nothing changed; every write is idempotent.
|
||||
@@ -87,7 +112,9 @@ export async function applyPresetSideEffects(
|
||||
|
||||
// Rail visibility lives on user_preferences and is mirrored to localStorage
|
||||
// with a change event, the same way the settings toggles write it.
|
||||
const hiddenRailTabs = JSON.stringify(target.rail.hiddenTabs);
|
||||
const hiddenRailTabs = JSON.stringify(
|
||||
presetHiddenRailTabs(target.rail, listRegisteredRailItems()),
|
||||
);
|
||||
writeLocal("hiddenRailTabs", hiddenRailTabs);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
if (isCloud) {
|
||||
|
||||
@@ -373,7 +373,14 @@ export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
|
||||
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
|
||||
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899");
|
||||
|
||||
export const logger = systemLogger;
|
||||
/** A named console logger, for plugins that want the same log format. */
|
||||
export function createFrontendLogger(
|
||||
serviceName: string,
|
||||
serviceIcon = "🧩",
|
||||
serviceColor = "#6b7280",
|
||||
) {
|
||||
return new FrontendLogger(serviceName, serviceIcon, serviceColor);
|
||||
}
|
||||
@@ -70,10 +70,6 @@ export function getCachedServerStatuses(
|
||||
return statusCache.get(loader);
|
||||
}
|
||||
|
||||
export function invalidateSSHHostsCache(): void {
|
||||
hostsCache.invalidate();
|
||||
}
|
||||
|
||||
export function invalidateServerStatusCache(): void {
|
||||
statusCache.invalidate();
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* The shape an axios rejection actually has at the call sites that inspect it.
|
||||
* A `catch` binding is `unknown`, and every one of these sites was reaching
|
||||
* into it directly; this gives them one narrowing helper instead.
|
||||
*/
|
||||
export interface HttpLikeError {
|
||||
message?: string;
|
||||
code?: string;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
code?: string;
|
||||
fileNotFound?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function asHttpError(error: unknown): HttpLikeError {
|
||||
return (error ?? {}) as HttpLikeError;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
export interface KeyedRequestCache<T> {
|
||||
get(
|
||||
key: string,
|
||||
loader: () => Promise<T>,
|
||||
options?: { force?: boolean },
|
||||
): Promise<T>;
|
||||
peek(key: string, maxAgeMs?: number): T | null;
|
||||
set(key: string, value: T): void;
|
||||
invalidate(key?: string): void;
|
||||
}
|
||||
|
||||
/** Bounded per-key cache with in-flight request coalescing. */
|
||||
export function createKeyedRequestCache<T>(
|
||||
ttlMs: number,
|
||||
maxEntries = 100,
|
||||
): KeyedRequestCache<T> {
|
||||
const values = new Map<string, { value: T; storedAt: number }>();
|
||||
const inflight = new Map<string, Promise<T>>();
|
||||
const versions = new Map<string, number>();
|
||||
|
||||
const store = (key: string, value: T) => {
|
||||
values.delete(key);
|
||||
values.set(key, { value, storedAt: Date.now() });
|
||||
while (values.size > Math.max(1, maxEntries)) {
|
||||
const oldest = values.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
values.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get(key, loader, options = {}) {
|
||||
const cached = values.get(key);
|
||||
if (!options.force && cached && Date.now() - cached.storedAt < ttlMs) {
|
||||
return Promise.resolve(cached.value);
|
||||
}
|
||||
|
||||
const pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const version = versions.get(key) ?? 0;
|
||||
const request = loader()
|
||||
.then((value) => {
|
||||
if ((versions.get(key) ?? 0) === version) store(key, value);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(key) === request) inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
peek(key, maxAgeMs = Number.POSITIVE_INFINITY) {
|
||||
const cached = values.get(key);
|
||||
if (!cached || Date.now() - cached.storedAt > maxAgeMs) return null;
|
||||
values.delete(key);
|
||||
values.set(key, cached);
|
||||
return cached.value;
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1);
|
||||
inflight.delete(key);
|
||||
store(key, value);
|
||||
},
|
||||
|
||||
invalidate(key) {
|
||||
if (key === undefined) {
|
||||
for (const activeKey of inflight.keys()) {
|
||||
versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1);
|
||||
}
|
||||
values.clear();
|
||||
inflight.clear();
|
||||
} else {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1);
|
||||
values.delete(key);
|
||||
inflight.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Thin, typed access to the desktop app's local filesystem bridge
|
||||
// (electron/local-files.cjs via preload.js). Everything here resolves to
|
||||
// "unavailable" outside Electron so callers can feature-detect cheaply.
|
||||
|
||||
import type {
|
||||
LocalDirectoryListing,
|
||||
LocalFsHomeInfo,
|
||||
LocalFsResult,
|
||||
LocalTrashResult,
|
||||
LocalWalkResult,
|
||||
} from "@/types/electron";
|
||||
import { isElectron } from "./electron";
|
||||
|
||||
export function isLocalFileBrowserAvailable(): boolean {
|
||||
if (!isElectron()) return false;
|
||||
const api = window.electronAPI;
|
||||
return !!api?.localFs && !!api?.localTransfer;
|
||||
}
|
||||
|
||||
function requireLocalFs() {
|
||||
const api = window.electronAPI?.localFs;
|
||||
if (!api) {
|
||||
throw new Error("Local file access is only available in the desktop app");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function unwrap<T>(result: LocalFsResult<T>): T {
|
||||
if (!result || result.success !== true) {
|
||||
const message =
|
||||
result && "error" in result && result.error
|
||||
? result.error
|
||||
: "Local file operation failed";
|
||||
const error = new Error(message) as Error & { code?: string };
|
||||
if (result && "code" in result) error.code = result.code;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getLocalHome(): Promise<LocalFsHomeInfo> {
|
||||
return unwrap(await requireLocalFs().home());
|
||||
}
|
||||
|
||||
export async function listLocalDirectory(
|
||||
dirPath: string,
|
||||
): Promise<LocalDirectoryListing> {
|
||||
return unwrap(await requireLocalFs().list(dirPath));
|
||||
}
|
||||
|
||||
export async function createLocalFolder(
|
||||
parentPath: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().mkdir(parentPath, name)).path;
|
||||
}
|
||||
|
||||
export async function createLocalFile(
|
||||
parentPath: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().createFile(parentPath, name)).path;
|
||||
}
|
||||
|
||||
export async function renameLocalEntry(
|
||||
oldPath: string,
|
||||
newName: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().rename(oldPath, newName)).path;
|
||||
}
|
||||
|
||||
/** Moves entries to the OS trash; resolves with per-path failures, if any. */
|
||||
export async function trashLocalPaths(
|
||||
paths: string[],
|
||||
): Promise<LocalTrashResult> {
|
||||
return unwrap(await requireLocalFs().trash(paths));
|
||||
}
|
||||
|
||||
/** Subset of `paths` that already exist on disk. */
|
||||
export async function localPathsExist(paths: string[]): Promise<string[]> {
|
||||
if (paths.length === 0) return [];
|
||||
return unwrap(await requireLocalFs().exists(paths)).existing;
|
||||
}
|
||||
|
||||
/** Creates `dirPath` (and parents). With `rootPath`, refuses anything outside it. */
|
||||
export async function ensureLocalDirectory(
|
||||
dirPath: string,
|
||||
rootPath?: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().ensureDir(dirPath, rootPath)).path;
|
||||
}
|
||||
|
||||
export async function walkLocalPaths(
|
||||
paths: string[],
|
||||
): Promise<LocalWalkResult> {
|
||||
return unwrap(await requireLocalFs().walk(paths));
|
||||
}
|
||||
|
||||
export async function revealLocalPath(targetPath: string): Promise<void> {
|
||||
unwrap(await requireLocalFs().reveal(targetPath));
|
||||
}
|
||||
|
||||
export async function openLocalPath(targetPath: string): Promise<void> {
|
||||
unwrap(await requireLocalFs().open(targetPath));
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
/** The backend port. Everything plugin-facing rides the main server. */
|
||||
const BACKEND_PORT = 30001;
|
||||
|
||||
export function pluginApiPath(pluginId: string, path = ""): string {
|
||||
function pluginApiPath(pluginId: string, path = ""): string {
|
||||
const suffix = path && !path.startsWith("/") ? `/${path}` : path;
|
||||
return `/plugin-api/${pluginId}${suffix}`;
|
||||
}
|
||||
@@ -123,23 +123,6 @@ export function pluginApiFor(
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* pluginWsUrl for a path a backend handed out ("/plugin-ws/<id>/<path>?q"),
|
||||
* so a caller can dial a plugin's socket without naming the plugin.
|
||||
*/
|
||||
export async function pluginWsUrlForPath(
|
||||
wsPath: string,
|
||||
options: { origin?: ConnectionOrigin } = {},
|
||||
): Promise<WebSocketConnectionTarget | null> {
|
||||
const [pathname, query] = wsPath.split("?", 2);
|
||||
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
const target = await pluginWsUrl(match[1], match[2], options);
|
||||
if (!target || !query) return target;
|
||||
const separator = target.url.includes("?") ? "&" : "?";
|
||||
return { ...target, url: `${target.url}${separator}${query}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
|
||||
* carry the JWT.
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface SharedHostConnectionAuth {
|
||||
keyType?: string | null;
|
||||
}
|
||||
|
||||
export async function getRemoteSharedHostConnectionAuth(
|
||||
async function getRemoteSharedHostConnectionAuth(
|
||||
localHostId: number,
|
||||
): Promise<SharedHostConnectionAuth> {
|
||||
if (localHostId >= 0) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { invokeAction } from "@/shell/action-registry";
|
||||
|
||||
/** A snippet as core's pickers show it. */
|
||||
export interface SnippetRow {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
folder?: string | null;
|
||||
isNote?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's snippets, from whichever plugin provides them through the
|
||||
* "snippet.list" action. Empty while no plugin does. An admin editing
|
||||
* another user's host passes that user's id.
|
||||
*/
|
||||
export async function listSnippets(options?: {
|
||||
targetUserId?: string;
|
||||
}): Promise<SnippetRow[]> {
|
||||
const rows = await invokeAction("snippet.list", options);
|
||||
return Array.isArray(rows) ? (rows as SnippetRow[]) : [];
|
||||
}
|
||||
@@ -27,11 +27,6 @@ export function extractSnippetInputs(content: string): SnippetInput[] {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
export function hasSnippetInputs(content: string): boolean {
|
||||
INPUT_PATTERN.lastIndex = 0;
|
||||
return INPUT_PATTERN.test(content);
|
||||
}
|
||||
|
||||
function replaceVar(
|
||||
content: string,
|
||||
name: string,
|
||||
|
||||
@@ -5,11 +5,6 @@ export const splitDragState = { active: false };
|
||||
// Callbacks registered by terminal instances to trigger a fit after drag ends.
|
||||
const fitCallbacks = new Set<() => void>();
|
||||
|
||||
export function registerFitCallback(fn: () => void) {
|
||||
fitCallbacks.add(fn);
|
||||
return () => fitCallbacks.delete(fn);
|
||||
}
|
||||
|
||||
export function notifyDragEnd() {
|
||||
for (const fn of fitCallbacks) fn();
|
||||
}
|
||||
Reference in new issue
Block a user