mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user