fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

+4
View File
@@ -157,6 +157,10 @@
"label": "Command History",
"description": "Allow command history recording. When disabled, history is not saved regardless of per-host settings."
},
"commandHistoryForNewHosts": {
"label": "Command history for new hosts",
"description": "Whether new hosts start with command history on."
},
"imageStorageMode": {
"label": "Storage Mode",
"description": "Where pasted and uploaded terminal images are written. Auto picks by connection, Local and Remote (SFTP) are fixed.",
+12 -10
View File
@@ -135,6 +135,13 @@
"group": "settings.admin.group.sessions",
"default": true
},
{
"key": "commandHistoryForNewHosts",
"type": "boolean",
"labelKey": "settings.admin.commandHistoryForNewHosts.label",
"descriptionKey": "settings.admin.commandHistoryForNewHosts.description",
"default": true
},
{
"key": "touchInput",
"type": "custom",
@@ -208,20 +215,15 @@
}
],
"host": {
"enableKey": "enableTerminal",
"enableLabelKey": "settings.host.enableTerminal.label",
"enableDefault": true,
"fields": [
{
"key": "enableTerminal",
"type": "boolean",
"labelKey": "settings.host.enableTerminal.label",
"descriptionKey": "settings.host.enableTerminal.description",
"default": true
},
{
"key": "enableTerminalToolbar",
"type": "boolean",
"labelKey": "settings.host.enableTerminalToolbar.label",
"descriptionKey": "settings.host.enableTerminalToolbar.description",
"requires": "enableTerminal",
"default": true
},
{
@@ -229,8 +231,8 @@
"type": "boolean",
"labelKey": "settings.host.enableCommandHistory.label",
"descriptionKey": "settings.host.enableCommandHistory.description",
"requires": "enableTerminal",
"default": true
"default": true,
"defaultFrom": "commandHistoryForNewHosts"
}
]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -22,6 +22,7 @@ import {
import { ADMIN_KEYS } from "./settings.js";
import { commandHistory } from "./tables.js";
import { createTerminalSocket } from "./terminal-socket.js";
import { validateAdminSettings } from "./settings-validation.js";
function toInfo(session: TerminalSession): LiveSessionInfo {
return {
@@ -55,6 +56,7 @@ function optionalService<T extends object>(
export async function activate(ctx: PluginContext) {
const log = createTerminalLogger(ctx.log);
ctx.settings.onValidate("admin", validateAdminSettings);
const table = await ctx.db.define(commandHistory);
const history = createHistoryRepository(ctx.db, table);
@@ -0,0 +1,32 @@
import {
parseImageHostPath,
parseImageLocalDir,
} from "./images/image-storage-settings.js";
import { ADMIN_KEYS } from "./settings.js";
/**
* Save-time checks for the admin settings: the image storage paths must be
* absolute, since a relative one silently depends on the server's cwd.
*/
export function validateAdminSettings(
values: Record<string, unknown>,
): Record<string, string> {
const errors: Record<string, string> = {};
const localDir = values[ADMIN_KEYS.imageLocalDir];
if (
typeof localDir === "string" &&
localDir.trim() !== "" &&
parseImageLocalDir(localDir) === null
) {
errors[ADMIN_KEYS.imageLocalDir] = "Use an absolute path";
}
const hostPath = values[ADMIN_KEYS.imageHostPath];
if (
typeof hostPath === "string" &&
hostPath.trim() !== "" &&
parseImageHostPath(hostPath) === null
) {
errors[ADMIN_KEYS.imageHostPath] = "Use an absolute path";
}
return errors;
}
@@ -81,7 +81,6 @@ interface ConnectToHostData {
keepaliveCountMax?: number;
[key: string]: unknown;
};
enableSessionLogging?: boolean;
/** When true, ignore key material and force password auth (fallback path). */
passwordFallbackOnly?: boolean;
};
@@ -1432,7 +1431,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
socks5ProxyChain?: unknown;
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
enableSessionLogging?: boolean;
})
| null = null;
@@ -1776,10 +1774,8 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
const hostDisplayName = `${username}@${ip}:${port}`;
const tabInstanceId = hostConfig.instanceId;
const sessionLoggingEnabled =
resolvedHostData?.enableSessionLogging ??
hostConfig.enableSessionLogging ??
true;
// The recordings.writer provider decides per host and user.
const sessionLoggingEnabled = true;
currentSessionId = sessionManager.createSession(
userId,
id,
@@ -107,24 +107,23 @@ import {
getNextTerminalFontSize,
getTerminalFontZoomDirection,
hydrateLocalSharedHostAuth,
getUserPreferences,
parseCustomKeybindings,
findMatchingKeybinding,
SnippetVariablesDialog,
type CustomKeybinding,
useConnectionDefaults,
getCookie,
isElectron,
logActivity,
getHostPassword,
patchOpenTab,
setHostAutoTmux,
} from "@termix/plugin-sdk/ui";
import {
useTranslation,
invokeAction,
usePluginApi,
useSlotContributions,
getCustomKeybindings,
getClientPreference,
logActivity,
getHostPassword,
patchOpenTab,
setHostAutoTmux,
} from "@termix/plugin-sdk/frontend";
type HostKeyVerificationData = Omit<
@@ -412,8 +411,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const totpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activityLoggedRef = useRef(false);
const commandHistoryTrackingEnabled =
hostConfig.enableCommandHistory !== false;
const commandHistoryTrackingEnabled = hostSetting(
hostConfig,
"enableCommandHistory",
true,
);
const { trackInput, getCurrentCommand, updateCurrentCommand } =
useCommandTracker({
@@ -758,6 +760,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
[clearAutosuggestion, terminal, trackInput],
);
const slotApiRef = useRef<TerminalSlotApi | null>(null);
const slotApi = useMemo<TerminalSlotApi>(
() => ({
host,
@@ -791,6 +794,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
hostConfig.instanceId,
],
);
slotApiRef.current = slotApi;
const overlayProps = useMemo<TerminalOverlayProps>(
() => ({
@@ -1426,6 +1430,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
refresh: () => hardRefresh(),
getApplicationCursorKeysMode: () =>
terminal?.modes?.applicationCursorKeysMode ?? false,
// What the tab menu's share entry needs, read at call time.
getShareTarget: () => slotApiRef.current?.getShareTarget() ?? null,
openFileManager: () => {
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({ type: "get_cwd" }));
@@ -1438,7 +1444,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
);
function getCopyOnSelect() {
return getCookie("copyOnSelect") === "true";
return getClientPreference("copyOnSelect") === "true";
}
function attemptReconnection() {
@@ -2945,12 +2951,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
useEffect(() => {
let cancelled = false;
const loadKeybindings = () => {
getUserPreferences()
.then((prefs) => {
getCustomKeybindings()
.then((bindings) => {
if (!cancelled) {
customKeybindingsRef.current = parseCustomKeybindings(
prefs.customKeybindings,
).filter((kb) => kb.enabled);
customKeybindingsRef.current = bindings.filter(
(kb) => kb.enabled,
);
}
})
.catch(() => {
@@ -43,8 +43,12 @@ import {
useIsMobile,
} from "@termix/plugin-sdk/ui";
import {
useHostActions,
useTabs,
useTranslation,
useSlotContributions,
type PluginHostRecord,
type ShellApi,
} from "@termix/plugin-sdk/frontend";
type SelectedToolbarDensity = ToolbarDensity;
@@ -120,6 +124,25 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
),
[allSlotContributions],
);
// Quick links to the other tools a plugin offers for this host (tmux
// monitor, docker, tunnels, host metrics), from their host actions.
const hostActions = useHostActions();
const tabs = useTabs();
const hostLinks = useMemo(() => {
const record = host as unknown as PluginHostRecord;
return hostActions.filter(
(action) =>
action.kind === "open" &&
!action.items &&
(action.run || action.tabType) &&
action.when(record),
);
}, [hostActions, host]);
const openHostLink = (action: (typeof hostLinks)[number]) => {
const record = host as unknown as PluginHostRecord;
if (action.run) action.run(record, tabs as unknown as ShellApi);
else if (action.tabType) tabs.openTab(record, action.tabType);
};
const [density, setDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [responsiveDensity, setResponsiveDensity] =
@@ -233,6 +256,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
isTmuxAttached,
actionsEnabled,
slotContributions,
hostLinks,
t,
]);
@@ -603,6 +627,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
{density !== "icon" && t(contribution.titleKey)}
</span>
))}
{actionsEnabled &&
hostLinks.map((action) => (
<span key={action.id} className={CONTROL}>
<action.icon className="size-4" />
{density !== "icon" && t(action.titleKey)}
</span>
))}
<span className={cn(CONTROL, "h-8 px-2")}>
<LayoutGrid className="size-4" />
<ChevronDown className="size-4" />
@@ -683,6 +714,24 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
/>
</>
)}
{actionsEnabled && hostLinks.length > 0 && (
<>
<div className={SEPARATOR} />
{hostLinks.map((action) => (
<button
key={action.id}
type="button"
className={CONTROL}
aria-label={t(action.titleKey)}
title={t(action.titleKey)}
onClick={() => openHostLink(action)}
>
<action.icon className="size-4" />
{effectiveDensity !== "icon" && t(action.titleKey)}
</button>
))}
</>
)}
<div className={SEPARATOR} />
{densitySelect}
<button
@@ -1,9 +1,9 @@
import { getClientPreference } from "@termix/plugin-sdk/frontend";
import type { Terminal } from "@xterm/xterm";
import { getCookie } from "@termix/plugin-sdk/ui";
import { isPhysicalShortcutKey } from "./terminal-key-event";
export function getUseRightClickCopyPaste(): boolean {
return getCookie("rightClickCopyPaste") !== "false";
return getClientPreference("rightClickCopyPaste") !== "false";
}
export interface TerminalClipboardActions {
@@ -27,7 +27,9 @@ afterEach(async () => {
describe("ssh-terminal activate", () => {
it("serves the terminal socket", async () => {
server = await startServer();
expect(server.mock.wsRoutes).toEqual([{ path: "/terminal", raw: false }]);
expect(server.mock.wsRoutes).toMatchObject([
{ path: "/terminal", raw: false },
]);
});
it("provides sessions.live as ssh and terminal.history", async () => {
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { validateAdminSettings } from "../../src/backend/settings-validation.js";
describe("validateAdminSettings", () => {
it("rejects relative image paths", () => {
expect(
Object.keys(
validateAdminSettings({
imageLocalDir: "images",
imageHostPath: "tmp/images",
}),
).sort(),
).toEqual(["imageHostPath", "imageLocalDir"]);
});
it("accepts absolute paths and empty values", () => {
expect(
validateAdminSettings({ imageLocalDir: "", imageHostPath: "/tmp/img" }),
).toEqual({});
});
});
@@ -18,6 +18,8 @@ const mobileApi = vi.hoisted(() => ({
const slots = vi.hoisted(() => ({
toolbar: [] as Array<{ actionId: string; titleKey: string }>,
invoked: [] as unknown[][],
hostActions: [] as Array<Record<string, unknown>>,
tabs: { openTab: vi.fn(), openSingletonTab: vi.fn() },
}));
const labels: Record<string, string> = {
@@ -52,6 +54,8 @@ vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
}),
useSlotContributions: (slotId: string) =>
slotId === "terminal.toolbar" ? slots.toolbar : [],
useHostActions: () => slots.hostActions,
useTabs: () => slots.tabs,
}));
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
@@ -120,6 +124,7 @@ beforeEach(() => {
localStorage.clear();
slots.toolbar = [];
slots.invoked = [];
slots.hostActions = [];
});
afterEach(() => {
@@ -875,6 +880,59 @@ describe("TerminalToolbar Phase 1", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
describe("host quick links", () => {
const Icon = () => null;
it("offers other plugins' open actions for this host", async () => {
const run = vi.fn();
slots.hostActions = [
{
id: "docker",
titleKey: "Docker",
icon: Icon,
kind: "open",
when: () => true,
run,
},
{
id: "gone",
titleKey: "Hidden",
icon: Icon,
kind: "open",
when: () => false,
run,
},
{
id: "terminal",
titleKey: "Terminal",
icon: Icon,
kind: "connect",
when: () => true,
tabType: "terminal",
},
{
id: "tunnel",
titleKey: "Tunnels",
icon: Icon,
kind: "open",
when: () => true,
tabType: "tunnel",
},
];
renderToolbar();
expect(screen.queryByRole("button", { name: "Hidden" })).toBeNull();
expect(screen.queryByRole("button", { name: "Terminal" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Docker" }));
expect(run).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: "Tunnels" }));
expect(slots.tabs.openTab).toHaveBeenCalledWith(
expect.anything(),
"tunnel",
);
});
});
describe("contributed toolbar actions", () => {
it("shows no contributed button and no stray separator when nothing contributed", () => {
const { container } = renderToolbar();
@@ -3,8 +3,8 @@ import type { Terminal } from "@xterm/xterm";
const getCookieMock = vi.fn<(name: string) => string | undefined>();
vi.mock("@termix/plugin-sdk/ui", () => ({
getCookie: (name: string) => getCookieMock(name),
vi.mock("@termix/plugin-sdk/frontend", () => ({
getClientPreference: (name: string) => getCookieMock(name),
}));
import {