feat: merge host editor terminal tabs into one owned by ssh-terminal

This commit is contained in:
LukeGus committed 2026-09-27 14:03:02 -05:00
1 parent fff373d5bd
commit c7fd900732
10 files changed
+162 -986

No files matched your search

+3
View File
@@ -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"
} }
} }
+11 -1
View File
@@ -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");
-1
View File
@@ -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",
+11
View File
@@ -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";
+42
View File
@@ -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,
File diff suppressed because it is too large. Load diff
+3 -9
View File
@@ -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,
+1 -5
View File
@@ -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("");
});
});