mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: merge host editor terminal tabs into one owned by ssh-terminal
This commit is contained in:
1 parent
fff373d5bd
commit
c7fd900732
10 files changed
+162
-986
No files matched your search
@@ -220,5 +220,8 @@
|
|||||||
"title": "Read command history",
|
"title": "Read command history",
|
||||||
"description": "Lets other features, such as the AI assistant, read your command history."
|
"description": "Lets other features, such as the AI assistant, read your command history."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"hosts": {
|
||||||
|
"tabTerminal": "Terminal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense, type ComponentType } from "react";
|
import { lazy, Suspense, type ComponentType } from "react";
|
||||||
import { History, Laptop, Terminal } from "lucide-react";
|
import { History, Laptop, SquareTerminal, Terminal } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
PanelProps,
|
PanelProps,
|
||||||
StandaloneViewProps,
|
StandaloneViewProps,
|
||||||
@@ -21,6 +21,7 @@ import { TerminalView } from "./TerminalView";
|
|||||||
import { HistoryPanel } from "./history/HistoryPanel";
|
import { HistoryPanel } from "./history/HistoryPanel";
|
||||||
import { TouchInputSettings } from "./settings/TouchInputSettings";
|
import { TouchInputSettings } from "./settings/TouchInputSettings";
|
||||||
import { ImageStorageTest } from "./settings/ImageStorageTest";
|
import { ImageStorageTest } from "./settings/ImageStorageTest";
|
||||||
|
import { HostTerminalSection } from "./settings/HostTerminalSection";
|
||||||
import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store";
|
import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store";
|
||||||
import { hostSetting } from "./terminal-api";
|
import { hostSetting } from "./terminal-api";
|
||||||
|
|
||||||
@@ -94,6 +95,15 @@ export function activate(app: TermixApp): void {
|
|||||||
!!host.enableSsh && hostSetting(host, "enableTerminal", true),
|
!!host.enableSsh && hostSetting(host, "enableTerminal", true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.registerHostEditorSection({
|
||||||
|
id: "terminal",
|
||||||
|
group: "ssh",
|
||||||
|
titleKey: "hosts.tabTerminal",
|
||||||
|
icon: SquareTerminal,
|
||||||
|
order: 10,
|
||||||
|
component: HostTerminalSection,
|
||||||
|
});
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
app.registerTab("local-terminal", LocalTerminalTab, {
|
app.registerTab("local-terminal", LocalTerminalTab, {
|
||||||
icon: Laptop,
|
icon: Laptop,
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ describe(`${manifest.id} activate`, () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("owns the host editor's Terminal tab in the SSH group", async () => {
|
||||||
|
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||||
|
expect(rendered.registered.hostEditorSections()).toEqual(["terminal"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("registers the command history panel and its rail item", async () => {
|
it("registers the command history panel and its rail item", async () => {
|
||||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||||
expect(rendered.registered.panels()).toContain("history");
|
expect(rendered.registered.panels()).toContain("history");
|
||||||
|
|||||||
@@ -344,7 +344,6 @@
|
|||||||
"uploadFile": "Upload File",
|
"uploadFile": "Upload File",
|
||||||
"tabGeneral": "General",
|
"tabGeneral": "General",
|
||||||
"tabSsh": "SSH",
|
"tabSsh": "SSH",
|
||||||
"tabTerminal": "Terminal",
|
|
||||||
"tabStats": "Host Metrics",
|
"tabStats": "Host Metrics",
|
||||||
"tabSharing": "Sharing",
|
"tabSharing": "Sharing",
|
||||||
"tabAuthentication": "Authentication",
|
"tabAuthentication": "Authentication",
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ export { useTabs, useTabsSafe } from "@/shell/TabContext";
|
|||||||
// Slots: a plugin can offer places for other plugins to fill.
|
// Slots: a plugin can offer places for other plugins to fill.
|
||||||
export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
||||||
|
|
||||||
|
// A plugin's manifest host settings, drawn inside its own host editor section.
|
||||||
|
export {
|
||||||
|
HostFeatureFields,
|
||||||
|
type HostFeatureFieldsProps,
|
||||||
|
} from "@/settings/HostPluginSections";
|
||||||
|
|
||||||
// Components other plugins offer by id (app.registerComponent), rendered with
|
// Components other plugins offer by id (app.registerComponent), rendered with
|
||||||
// a fallback while their plugin is off.
|
// a fallback while their plugin is off.
|
||||||
export { PluginComponent } from "@/plugin-host/component-registry";
|
export { PluginComponent } from "@/plugin-host/component-registry";
|
||||||
@@ -103,6 +109,11 @@ export * from "@/lib/terminal-themes";
|
|||||||
export { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme";
|
export { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme";
|
||||||
export { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles";
|
export { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles";
|
||||||
export * from "@/lib/terminal-look/terminal-font-zoom";
|
export * from "@/lib/terminal-look/terminal-font-zoom";
|
||||||
|
export { TerminalPreview } from "@/components/terminal-preview/TerminalPreview";
|
||||||
|
export {
|
||||||
|
HostTerminalSettings,
|
||||||
|
type HostTerminalSettingsProps,
|
||||||
|
} from "@/sidebar/HostTerminalSettings";
|
||||||
export { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
export { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
||||||
export { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
export { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||||
export { useTheme as useAppTheme } from "@/components/theme-provider";
|
export { useTheme as useAppTheme } from "@/components/theme-provider";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { SectionCard } from "@/components/section-card";
|
import { SectionCard } from "@/components/section-card";
|
||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||||
|
import { usePluginScope } from "@/plugin-host/scope";
|
||||||
import { SettingsFieldRow } from "./SettingsFields";
|
import { SettingsFieldRow } from "./SettingsFields";
|
||||||
import { hasVisibleFields, isFieldActive } from "./settings-fields-util";
|
import { hasVisibleFields, isFieldActive } from "./settings-fields-util";
|
||||||
|
|
||||||
@@ -112,6 +113,47 @@ export function HostPluginSections({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostFeatureFieldsProps {
|
||||||
|
// The host editor form, as handed to a host editor section.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
form: any;
|
||||||
|
updateForm: (
|
||||||
|
patch: (form: Record<string, unknown>) => Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calling plugin's manifest host settings, for a plugin that registered
|
||||||
|
* its own host editor section and so gets no generated tab.
|
||||||
|
*/
|
||||||
|
export function HostFeatureFields({
|
||||||
|
form,
|
||||||
|
updateForm,
|
||||||
|
}: HostFeatureFieldsProps) {
|
||||||
|
const pluginId = usePluginScope();
|
||||||
|
const plugin = usePluginHostSections().find((p) => p.id === pluginId);
|
||||||
|
if (!plugin) return null;
|
||||||
|
const values = (form?.pluginSettings as HostPluginSettings | undefined) ?? {};
|
||||||
|
return (
|
||||||
|
<HostPluginSection
|
||||||
|
plugin={plugin}
|
||||||
|
values={values[plugin.id] ?? {}}
|
||||||
|
setValue={(key, value) =>
|
||||||
|
updateForm((current) => {
|
||||||
|
const all = (current.pluginSettings ?? {}) as HostPluginSettings;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
pluginSettings: {
|
||||||
|
...all,
|
||||||
|
[plugin.id]: { ...(all[plugin.id] ?? {}), [key]: value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HostPluginSection({
|
function HostPluginSection({
|
||||||
plugin,
|
plugin,
|
||||||
values,
|
values,
|
||||||
|
|||||||
+29
-968
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
|
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
|
||||||
import { KeyRound, Settings, SquareTerminal, Terminal } from "lucide-react";
|
import { KeyRound, Settings, Terminal } from "lucide-react";
|
||||||
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||||
|
|
||||||
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
|
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
|
||||||
export type CoreHostTabId = "general" | "ssh" | "terminal-options";
|
export type CoreHostTabId = "general" | "ssh";
|
||||||
export type HostTabId = CoreHostTabId | (string & {});
|
export type HostTabId = CoreHostTabId | (string & {});
|
||||||
export type CredentialTabId = "general" | "auth";
|
export type CredentialTabId = "general" | "auth";
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export const hostEditorSectionList = sections.list;
|
|||||||
export const useHostEditorSections = sections.useList;
|
export const useHostEditorSections = sections.useList;
|
||||||
export const resetHostEditorSections = sections.reset;
|
export const resetHostEditorSections = sections.reset;
|
||||||
|
|
||||||
const CORE_SSH_GROUP = new Set<string>(["ssh", "terminal-options"]);
|
const CORE_SSH_GROUP = new Set<string>(["ssh"]);
|
||||||
|
|
||||||
/** Whether a tab lives in the SSH group's second strip. */
|
/** Whether a tab lives in the SSH group's second strip. */
|
||||||
export function isSshGroupTab(id: string): boolean {
|
export function isSshGroupTab(id: string): boolean {
|
||||||
@@ -149,12 +149,6 @@ export function makeHostSshSubTabs(
|
|||||||
icon: <Settings className="size-3" />,
|
icon: <Settings className="size-3" />,
|
||||||
order: 0,
|
order: 0,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "terminal-options",
|
|
||||||
label: t("hosts.tabTerminal"),
|
|
||||||
icon: <SquareTerminal className="size-3" />,
|
|
||||||
order: 10,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"ssh",
|
"ssh",
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -244,11 +244,7 @@ describe("registries through the app object", () => {
|
|||||||
it("adds a host editor section to the SSH group in order", async () => {
|
it("adds a host editor section to the SSH group in order", async () => {
|
||||||
await mount();
|
await mount();
|
||||||
const ids = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
|
const ids = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
|
||||||
expect(ids.slice(0, 3)).toEqual([
|
expect(ids.slice(0, 2)).toEqual(["ssh", "fixture-section"]);
|
||||||
"ssh",
|
|
||||||
"terminal-options",
|
|
||||||
"fixture-section",
|
|
||||||
]);
|
|
||||||
expect(makeHostTabs((key) => key).map((tab) => tab.id)).not.toContain(
|
expect(makeHostTabs((key) => key).map((tab) => tab.id)).not.toContain(
|
||||||
"fixture-section",
|
"fixture-section",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import type { PluginSummary } from "@/api/plugins-api";
|
import type { PluginSummary } from "@/api/plugins-api";
|
||||||
import {
|
import {
|
||||||
hostFeatureTabId,
|
hostFeatureTabId,
|
||||||
@@ -14,7 +20,16 @@ import {
|
|||||||
registerHostEditorSection,
|
registerHostEditorSection,
|
||||||
resetHostEditorSections,
|
resetHostEditorSections,
|
||||||
} from "@/sidebar/HostManagerTabs";
|
} from "@/sidebar/HostManagerTabs";
|
||||||
import { HostPluginSections } from "@/settings/HostPluginSections";
|
import {
|
||||||
|
HostFeatureFields,
|
||||||
|
HostPluginSections,
|
||||||
|
} from "@/settings/HostPluginSections";
|
||||||
|
import { withPluginScope } from "@/plugin-host/scope";
|
||||||
|
|
||||||
|
const getPlugins = vi.fn();
|
||||||
|
vi.mock("@/api/plugins-api", () => ({
|
||||||
|
getPlugins: () => getPlugins(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", () => ({
|
||||||
useTranslation: () => ({ t: (key: string) => key }),
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
@@ -266,3 +281,43 @@ describe("HostPluginSections", () => {
|
|||||||
expect(screen.getByDisplayValue("web")).toBeTruthy();
|
expect(screen.getByDisplayValue("web")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("HostFeatureFields", () => {
|
||||||
|
it("draws the calling plugin's fields and writes into its pluginSettings", async () => {
|
||||||
|
getPlugins.mockResolvedValue([
|
||||||
|
plugin(),
|
||||||
|
plugin({ id: "warpgate", name: "Warpgate" }),
|
||||||
|
]);
|
||||||
|
const form = {
|
||||||
|
pluginSettings: {
|
||||||
|
docker: { enableDocker: false },
|
||||||
|
warpgate: { enableDocker: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const updateForm = vi.fn();
|
||||||
|
const Scoped = withPluginScope("docker", HostFeatureFields);
|
||||||
|
render(<Scoped form={form} updateForm={updateForm} />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("docker:enableDocker.label")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("Docker")).toHaveLength(1);
|
||||||
|
expect(screen.queryByText("Warpgate")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button")[0]);
|
||||||
|
const patch = updateForm.mock.calls[0][0];
|
||||||
|
expect(patch(form).pluginSettings).toEqual({
|
||||||
|
docker: { enableDocker: true },
|
||||||
|
warpgate: { enableDocker: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing outside a plugin", async () => {
|
||||||
|
getPlugins.mockResolvedValue([plugin()]);
|
||||||
|
const { container } = render(
|
||||||
|
<HostFeatureFields form={{}} updateForm={vi.fn()} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(getPlugins).toHaveBeenCalled());
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user