* feat: add an auto or fixed columns picker to card grids

* feat: add safe mode that only runs layout and theme plugins

* docs: add simple layout to plugin table

* fix: ask for local network access in the macOS app

* feat: allow plugin registries on listed private hosts (#1705)

* chore: add private registry hosts to the changelog

* feat: use the proxy for plugin registry downloads

* fix: allow changing connection origin on shared hosts

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.5

---------

Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
This commit is contained in:
Luke GustafsonandLukas authored and GitHub committed 2026-10-22 13:30:48 -05:00
1 parent 7b7b17e31f
commit 96b8520fb4
96 files changed
+1885 -474

No files matched your search

+5 -1
View File
@@ -18,6 +18,7 @@ import {
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
import { safeModeOn } from "../../plugins/safe-mode.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -36,7 +37,7 @@ async function adminDefaults() {
* /user-preferences/look:
* get:
* summary: Get the layout and theme to show the current user
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, whether TERMIX_SAFE_MODE runs only layout and theme plugins, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* tags:
* - User Preferences
* responses:
@@ -75,6 +76,8 @@ async function adminDefaults() {
* nullable: true
* safeLayout:
* type: boolean
* safeMode:
* type: boolean
* unavailable:
* type: array
* items:
@@ -112,6 +115,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(),
safeMode: safeModeOn(),
unavailable: unavailableLooks(loader),
});
} catch (error) {
+20 -2
View File
@@ -16,6 +16,7 @@ import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -249,6 +250,9 @@ export async function listPreLoginPlugins(): Promise<
* only run on that server and stay off here on purpose, and, for
* admins, `managedByServer` marks plugins whose on/off state the server
* decides.
*
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
* off reports `state: "safe-mode"`.
* tags:
* - Plugins
* responses:
@@ -354,7 +358,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
state:
record.state === "enabled" &&
loaded?.state !== "active" &&
!mayRunInSafeMode(record.id, loader.list())
? "safe-mode"
: (loaded?.state ?? record.state),
runsOnServer: linked && desktopMode === "server",
contributes,
icon,
@@ -936,7 +945,7 @@ router.delete(
* 404:
* description: No such plugin.
* 409:
* description: This desktop is linked to a server, which decides whether the plugin runs.
* description: This desktop is linked to a server, which decides whether the plugin runs, or safe mode is on and the plugin is not a layout or theme.
*/
router.patch(
"/:id/state",
@@ -1050,6 +1059,8 @@ router.patch(
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin, or it is not on disk.
* 409:
* description: Safe mode is on and the plugin is not a layout or theme.
* 500:
* description: The plugin failed to start again.
*/
@@ -1073,6 +1084,13 @@ router.post(
res.status(404).json({ error: "Plugin is not present on disk" });
return;
}
if (!mayRunInSafeMode(pluginId, loader.list())) {
res.status(409).json({
error: "Safe mode is on, so only layout and theme plugins can start",
code: "SAFE_MODE",
});
return;
}
await repository.update(pluginId, {
state: "enabled",
+21
View File
@@ -14,3 +14,24 @@ export function startablePluginIds(
.map((record) => record.id),
);
}
/**
* Plugins that shipped in an earlier image and were left on, but whose code is
* not in this one (a smaller image tag). They get fetched from the registry.
*/
export function missingShippedPluginIds(
records: { id: string; tier: string; state: string }[],
presentIds: ReadonlySet<string>,
ignored: ReadonlySet<string>,
): string[] {
return records
.filter(
(record) =>
record.tier === "bundled" &&
record.state !== "disabled" &&
!presentIds.has(record.id) &&
!ignored.has(record.id),
)
.map((record) => record.id)
.sort();
}
+20
View File
@@ -23,6 +23,11 @@ import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js";
import { startablePluginIds } from "./boot-state.js";
import {
mayRunInSafeMode,
safeModeOn,
safeModePluginIds,
} from "./safe-mode.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
@@ -237,6 +242,10 @@ export async function initializePlugins(
if (loaded.length === 0) return [];
const fresh = await seedPlugins(loaded, options.freshInstall === true);
if (!fresh) {
const { restoreMissingPlugins } = await import("./manage.js");
loaded.push(...(await restoreMissingPlugins()));
}
await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) {
const { offerPluginSetup } = await import("./onboarding.js");
@@ -248,8 +257,17 @@ export async function initializePlugins(
const records = await createCurrentPluginRepository().listAll();
const enabled = startablePluginIds(records);
const safe = safeModeOn() ? safeModePluginIds(loaded) : null;
if (safe) {
pluginLogger.warn(
"Safe mode is on (TERMIX_SAFE_MODE). Only layout and theme plugins start",
{ operation: "plugin_init" },
);
}
const candidates = loaded
.filter((plugin) => enabled.has(plugin.id))
.filter((plugin) => !safe || safe.has(plugin.id))
.map((plugin) => plugin.id);
for (const pluginId of candidates) {
@@ -539,6 +557,8 @@ export async function activatePlugin(pluginId: string): Promise<void> {
// Before activate: a plugin's own activate() may already want to check one
// of its permissions.
const plugin = pluginLoader.get(pluginId);
// Stays enabled and starts on the next boot without safe mode.
if (!mayRunInSafeMode(pluginId, pluginLoader.list())) return;
if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId);
+71 -1
View File
@@ -18,6 +18,7 @@ import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import { mayRunInSafeMode } from "./safe-mode.js";
import {
activatePlugin,
deactivatePlugin,
@@ -25,6 +26,7 @@ import {
registerLoadedPlugins,
unloadPlugin,
} from "./index.js";
import { missingShippedPluginIds } from "./boot-state.js";
import type { LoadedPlugin, PluginLoader } from "./loader.js";
import {
getBundledPluginsDir,
@@ -763,6 +765,13 @@ export async function setPluginStateUnlocked(
if (!loader.get(pluginId)) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
if (enabled && !mayRunInSafeMode(pluginId, loader.list())) {
throw new PluginManageError(
"Safe mode is on, so only layout and theme plugins can start",
409,
"SAFE_MODE",
);
}
const plan = await planStateChange(pluginId, enabled);
if (enabled) {
@@ -959,7 +968,11 @@ export function installPlugin(
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
if (await repository.findById(pluginId)) {
// A record with no code is a plugin an older image shipped: install fills it in.
if (
(await repository.findById(pluginId)) &&
getPluginRuntime().loader.get(pluginId)
) {
throw new PluginManageError(
"Plugin is already installed",
409,
@@ -1018,6 +1031,63 @@ export function installPlugin(
});
}
/**
* Called at boot before plugins start. A plugin the database has on but the
* image no longer ships (an update to a smaller image tag) is fetched from the
* official registry, so nothing an install used goes missing. One that cannot
* be fetched stays as it was and is tried again on the next start.
*/
export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const { loader } = getPluginRuntime();
const ids = missingShippedPluginIds(
await repository.listAll(),
new Set(loader.list().map((plugin) => plugin.id)),
await readUninstalled(),
);
const restored: LoadedPlugin[] = [];
for (const id of ids) {
try {
const record = await repository.findById(id);
const { source, release } = await requireRelease(
id,
record?.pinnedVersion ?? undefined,
channelOf(record),
OFFICIAL_REGISTRY_ID,
);
const plugin = await swapIn(id, { release, source });
await repository.update(id, {
registryId: source.id,
tier: tierFor(plugin, source),
});
await grantDeclared(plugin, null);
restored.push(plugin);
pluginLogger.info(
`Restored ${id}@${release.version} from the registry`,
{
operation: "plugin_restore_missing",
},
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
operation: "plugin_restore_missing",
});
// The registry is down or offline, so the rest would fail the same way.
if (
error instanceof PluginManageError &&
error.code === "REGISTRY_UNREACHABLE"
) {
break;
}
}
}
return restored;
});
}
export function updatePlugin(
pluginId: string,
options: InstallOptions,
+65 -3
View File
@@ -12,6 +12,7 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { isIP } from "node:net";
import path from "node:path";
import semver from "semver";
import {
@@ -23,7 +24,11 @@ import {
youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest";
import { getLocalVersion } from "../utils/app-version.js";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import {
isBlockedAddress,
safeOutboundFetch,
} from "../utils/safe-outbound-fetch.js";
import { fetchWithProxy, getProxyAgent } from "../utils/proxy-agent.js";
import { pluginLogger } from "../utils/logger.js";
import {
COMMUNITY_PLUGIN_KEYS,
@@ -278,15 +283,72 @@ export function canRunRelease(
);
}
/**
* Hosts the admin lets registry fetches reach on a private address, like a
* registry in the LAN. Matched by host name on any port, also when another
* index or a redirect points there; every other host stays blocked.
*/
export function getRegistryPrivateHosts(): string[] {
return (process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean)
.map(normalizePrivateHost)
.filter((host): host is string => host !== undefined);
}
/**
* The host in the form safeOutboundFetch compares against, or undefined when
* the entry carries more than a host, like a scheme, port, path or user.
*/
function normalizePrivateHost(entry: string): string | undefined {
const candidate = isIP(entry) === 6 ? `[${entry}]` : entry;
if (/[/?#@\\]|:\d*$/.test(candidate)) return undefined;
try {
return new URL(`http://${candidate}`).hostname.replace(/^\[|\]$/g, "");
} catch {
return undefined;
}
}
/**
* With HTTP_PROXY or HTTPS_PROXY set, registry traffic goes through the proxy,
* which does its own name lookup. Only the URL itself is checked here: web
* pages only, no logins in it, and no private address typed in as a literal.
*/
function fetchThroughProxy(
url: string,
options: RequestInit,
privateHosts: readonly string[],
): Promise<Response> {
const parsed = new URL(url);
const host = parsed.hostname.replace(/^\[|\]$/g, "");
if (
!["http:", "https:"].includes(parsed.protocol) ||
parsed.username ||
parsed.password
) {
throw new Error("Invalid outbound URL");
}
if (isIP(host) && isBlockedAddress(host) && !privateHosts.includes(host)) {
throw new Error("Private destinations are not allowed");
}
return fetchWithProxy(url, options);
}
/** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> {
const privateHosts = getRegistryPrivateHosts();
let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = await safeOutboundFetch(current, {
const options: RequestInit = {
method: "GET",
redirect: "manual",
headers: { "User-Agent": "Termix" },
});
};
const response = getProxyAgent(current)
? await fetchThroughProxy(current, options, privateHosts)
: await safeOutboundFetch(current, options, privateHosts);
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) throw new Error(`redirect without a location: ${current}`);
+42
View File
@@ -0,0 +1,42 @@
import type { PluginManifest } from "./manifest.js";
/** TERMIX_SAFE_MODE=1 starts only the plugins that draw the app. */
export function safeModeOn(): boolean {
const value = process.env.TERMIX_SAFE_MODE?.trim().toLowerCase();
return value === "1" || value === "true";
}
function isLookPlugin(manifest: PluginManifest): boolean {
return (
(manifest.contributes?.layouts?.length ?? 0) > 0 ||
(manifest.contributes?.themes?.length ?? 0) > 0
);
}
/** Layout and theme plugins, plus the plugins they need to start. */
export function safeModePluginIds(
plugins: { id: string; manifest: PluginManifest }[],
): Set<string> {
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const allowed = new Set<string>();
const add = (id: string) => {
const plugin = byId.get(id);
if (!plugin || allowed.has(id)) return;
allowed.add(id);
for (const dependency of Object.keys(plugin.manifest.dependencies ?? {})) {
add(dependency);
}
};
for (const plugin of plugins) {
if (isLookPlugin(plugin.manifest)) add(plugin.id);
}
return allowed;
}
/** Whether a plugin may run now. Always true outside safe mode. */
export function mayRunInSafeMode(
pluginId: string,
plugins: { id: string; manifest: PluginManifest }[],
): boolean {
return !safeModeOn() || safeModePluginIds(plugins).has(pluginId);
}
+15
View File
@@ -7,6 +7,8 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory
* On a linked desktop, hosts and credentials someone shared with the account
* are read-only copies kept in step by sync. Editing or deleting one here
* would be undone by the next sync, so it is refused with a clear reason.
* A host's connection origin only means something on this device, so that one
* field can still be changed.
*/
export function rejectSharedCopyWrites(
kind: "host" | "credential",
@@ -25,6 +27,19 @@ export function rejectSharedCopyWrites(
.where(eq(table.id, Number(match[1])))
.limit(1);
if (row?.sharedSource) {
const origin = req.body?.connectionOrigin;
if (
kind === "host" &&
req.method === "PUT" &&
(origin === "local" || origin === "remote")
) {
await createCurrentRepositoryContext()
.drizzle.update(hosts)
.set({ connectionOrigin: origin })
.where(eq(hosts.id, Number(match[1])));
res.json({ id: Number(match[1]), connectionOrigin: origin });
return;
}
res.status(403).json({
error: "This was shared with you and is managed on the server",
code: "SHARED_COPY",
@@ -102,6 +102,7 @@ beforeEach(async () => {
state.defaultLookIds = [];
state.installed = [];
delete process.env.TERMIX_SAFE_LAYOUT;
delete process.env.TERMIX_SAFE_MODE;
const app = express();
app.use(express.json());
@@ -113,6 +114,7 @@ beforeEach(async () => {
afterEach(async () => {
delete process.env.TERMIX_SAFE_LAYOUT;
delete process.env.TERMIX_SAFE_MODE;
await new Promise<void>((resolve) => server.close(() => resolve()));
});
@@ -138,6 +140,7 @@ describe("GET /user-preferences/look", () => {
defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null },
safeLayout: false,
safeMode: false,
unavailable: [],
});
});
@@ -159,6 +162,7 @@ describe("GET /user-preferences/look", () => {
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
];
process.env.TERMIX_SAFE_LAYOUT = "1";
process.env.TERMIX_SAFE_MODE = "1";
const { body } = await getLook();
expect(body).toMatchObject({
@@ -167,6 +171,7 @@ describe("GET /user-preferences/look", () => {
defaults: { layout: "admin-layout", theme: "gruvbox" },
defaultLook: { layout: "shipped", theme: "ink" },
safeLayout: true,
safeMode: true,
});
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
["broken"],
@@ -488,6 +488,26 @@ describe("plugins route", () => {
}
});
it("reports an enabled plugin that safe mode keeps off", async () => {
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
const read = async () =>
(
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
state: string;
enabled: boolean;
}>
)[0];
expect((await read()).state).not.toBe("safe-mode");
process.env.TERMIX_SAFE_MODE = "1";
try {
expect(await read()).toMatchObject({ state: "safe-mode", enabled: true });
} finally {
delete process.env.TERMIX_SAFE_MODE;
}
});
it("grants a declared capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
+26 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { startablePluginIds } from "../../plugins/boot-state.js";
import {
missingShippedPluginIds,
startablePluginIds,
} from "../../plugins/boot-state.js";
describe("startablePluginIds", () => {
it("starts enabled plugins and retries ones that failed or were blocked", () => {
@@ -13,3 +16,25 @@ describe("startablePluginIds", () => {
expect([...ids].sort()).toEqual(["a", "b", "c"]);
});
});
describe("missingShippedPluginIds", () => {
const records = [
{ id: "gone", tier: "bundled", state: "enabled" },
{ id: "here", tier: "bundled", state: "enabled" },
{ id: "off", tier: "bundled", state: "disabled" },
{ id: "failed", tier: "bundled", state: "failed" },
{ id: "community", tier: "community", state: "enabled" },
{ id: "removed", tier: "bundled", state: "enabled" },
];
it("lists shipped plugins that are on but not in the image", () => {
expect(
missingShippedPluginIds(records, new Set(["here"]), new Set(["removed"])),
).toEqual(["failed", "gone"]);
});
it("finds nothing when every plugin is present", () => {
const present = new Set(records.map((record) => record.id));
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
});
});
+20
View File
@@ -621,6 +621,26 @@ describe("dependencies", () => {
});
});
describe("safe mode", () => {
afterEach(() => {
delete process.env.TERMIX_SAFE_MODE;
});
it("refuses to start a plugin that is not a layout or theme, but still stops one", async () => {
createFixturePlugin({ id: "base", root: bundled });
const loader = await boot();
await manage.setPluginState("base", false);
process.env.TERMIX_SAFE_MODE = "1";
await expect(manage.setPluginState("base", true)).rejects.toMatchObject({
status: 409,
code: "SAFE_MODE",
});
expect(loader.get("base")?.state).toBe("stopped");
expect(db.rows.get("base")?.state).toBe("disabled");
});
});
describe("reloading a bundled plugin for the dev runner", () => {
it("loads the rebuilt copy and restarts what depends on it", async () => {
createFixturePlugin({ id: "base", root: bundled });
@@ -8,10 +8,23 @@ import * as tar from "tar";
const fetchMock = vi.hoisted(() => vi.fn());
const testKey = vi.hoisted(() => ({ value: null as null | object }));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
vi.mock("../../utils/safe-outbound-fetch.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../utils/safe-outbound-fetch.js")
>()),
safeOutboundFetch: fetchMock,
}));
const proxyMock = vi.hoisted(() => ({
on: false,
fetch: vi.fn(),
}));
vi.mock("../../utils/proxy-agent.js", () => ({
getProxyAgent: () => (proxyMock.on ? {} : undefined),
fetchWithProxy: proxyMock.fetch,
}));
vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3",
}));
@@ -43,9 +56,11 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
import {
communitySource,
downloadRelease,
fetchIndexAt,
fetchRegistryIndex,
fetchRegistryStats,
findRelease,
getRegistryPrivateHosts,
latestOnChannel,
getStatsUrl,
canRunRelease,
@@ -409,6 +424,91 @@ describe("fetchRegistryIndex", () => {
});
});
describe("registry fetches with a proxy", () => {
afterEach(() => {
proxyMock.on = false;
proxyMock.fetch.mockReset();
});
it("goes through the proxy when one is configured", async () => {
proxyMock.on = true;
proxyMock.fetch.mockResolvedValueOnce(
response(JSON.stringify({ plugins: [] })),
);
await fetchIndexAt("https://registry.example/index.json");
expect(proxyMock.fetch).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
});
it("still refuses a private address typed in as a literal", async () => {
proxyMock.on = true;
await expect(fetchIndexAt("https://127.0.0.1/index.json")).rejects.toThrow(
"Private destinations are not allowed",
);
expect(proxyMock.fetch).not.toHaveBeenCalled();
});
});
describe("private registry hosts", () => {
afterEach(() => {
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
});
it("reads a comma separated list of host names", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
" GitLab.lan ,, 192.168.1.5 ";
expect(getRegistryPrivateHosts()).toEqual(["gitlab.lan", "192.168.1.5"]);
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
expect(getRegistryPrivateHosts()).toEqual([]);
});
it("brings each entry into the form the fetch compares against", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
"GitLab.LAN,[FD00::0001],fd00::2,bücher.lan";
expect(getRegistryPrivateHosts()).toEqual([
"gitlab.lan",
"fd00::1",
"fd00::2",
"xn--bcher-kva.lan",
]);
});
it("drops entries with more than a host", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
"gitlab.lan:8443,https://git.lan,[fd00::1]:80,nas.lan";
expect(getRegistryPrivateHosts()).toEqual(["nas.lan"]);
});
it("allows the listed hosts on every hop", async () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
fetchMock
.mockResolvedValueOnce(
response("", 302, { location: "https://gitlab.lan/raw/index.json" }),
)
.mockResolvedValueOnce(response(JSON.stringify({ plugins: [] })));
await fetchIndexAt("https://gitlab.lan/index.json");
expect(fetchMock.mock.calls.map((call) => call[2])).toEqual([
["gitlab.lan"],
["gitlab.lan"],
]);
});
it("still blocks a redirect to a private host that is not listed", async () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
const actual = await vi.importActual<
typeof import("../../utils/safe-outbound-fetch.js")
>("../../utils/safe-outbound-fetch.js");
fetchMock
.mockResolvedValueOnce(
response("", 302, { location: "https://127.0.0.1/index.json" }),
)
.mockImplementationOnce(actual.safeOutboundFetch);
await expect(fetchIndexAt("https://gitlab.lan/index.json")).rejects.toThrow(
"Private destinations are not allowed",
);
});
});
/** A real .tmxplug holding only a manifest, enough for the checks. */
function pack(manifest: Record<string, unknown>): Buffer {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it } from "vitest";
import {
mayRunInSafeMode,
safeModeOn,
safeModePluginIds,
} from "../../plugins/safe-mode.js";
import type { PluginManifest } from "../../plugins/manifest.js";
function plugin(id: string, manifest: Partial<PluginManifest> = {}) {
return { id, manifest: { id, ...manifest } as PluginManifest };
}
const plugins = [
plugin("layout", {
contributes: { layouts: [{ id: "classic" }] },
dependencies: { helper: "^1.0.0" },
} as Partial<PluginManifest>),
plugin("themes", {
contributes: { themes: [{ id: "dark" }] },
} as Partial<PluginManifest>),
plugin("helper"),
plugin("docker"),
];
afterEach(() => {
delete process.env.TERMIX_SAFE_MODE;
});
describe("safeModeOn", () => {
it("reads 1 and true, anything else is off", () => {
expect(safeModeOn()).toBe(false);
process.env.TERMIX_SAFE_MODE = "1";
expect(safeModeOn()).toBe(true);
process.env.TERMIX_SAFE_MODE = " TRUE ";
expect(safeModeOn()).toBe(true);
process.env.TERMIX_SAFE_MODE = "0";
expect(safeModeOn()).toBe(false);
});
});
describe("safeModePluginIds", () => {
it("keeps layout and theme plugins and what they depend on", () => {
expect([...safeModePluginIds(plugins)].sort()).toEqual([
"helper",
"layout",
"themes",
]);
});
});
describe("mayRunInSafeMode", () => {
it("lets every plugin run when safe mode is off", () => {
expect(mayRunInSafeMode("docker", plugins)).toBe(true);
});
it("only lets look plugins run when safe mode is on", () => {
process.env.TERMIX_SAFE_MODE = "1";
expect(mayRunInSafeMode("docker", plugins)).toBe(false);
expect(mayRunInSafeMode("layout", plugins)).toBe(true);
expect(mayRunInSafeMode("helper", plugins)).toBe(true);
});
});
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
row: { sharedSource: '{"owner":"x"}' } as { sharedSource: string | null },
updates: [] as unknown[],
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentRepositoryContext: () => ({
drizzle: {
select: () => ({
from: () => ({ where: () => ({ limit: async () => [h.row] }) }),
}),
update: () => ({
set: (values: unknown) => ({
where: async () => {
h.updates.push(values);
},
}),
}),
},
}),
}));
const { rejectSharedCopyWrites } =
await import("../../sync/shared-copy-guard.js");
function run(body: Record<string, unknown>) {
const guard = rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/);
const res = {
code: 0,
payload: undefined as unknown,
status(code: number) {
this.code = code;
return this;
},
json(payload: unknown) {
this.payload = payload;
return this;
},
};
const next = vi.fn();
return guard(
{ method: "PUT", path: "/db/host/5", body } as never,
res as never,
next,
).then(() => ({ res, next }));
}
beforeEach(() => {
process.env.ELECTRON_EMBEDDED = "true";
h.row = { sharedSource: '{"owner":"x"}' };
h.updates = [];
});
describe("shared copy guard", () => {
it("refuses edits to a shared copy", async () => {
const { res, next } = await run({ name: "new" });
expect(res.code).toBe(403);
expect(next).not.toHaveBeenCalled();
expect(h.updates).toEqual([]);
});
it("still lets the connection origin change on this device", async () => {
const { res, next } = await run({ name: "new", connectionOrigin: "local" });
expect(res.code).toBe(0);
expect(res.payload).toMatchObject({ connectionOrigin: "local" });
expect(next).not.toHaveBeenCalled();
expect(h.updates).toEqual([{ connectionOrigin: "local" }]);
});
it("passes hosts that are not shared copies", async () => {
h.row = { sharedSource: null };
const { next } = await run({ name: "new" });
expect(next).toHaveBeenCalled();
});
});
+2
View File
@@ -19,6 +19,8 @@ export interface LookResponse {
defaultLook: { layout: string | null; theme: string | null };
/** TERMIX_SAFE_LAYOUT is set on the server. */
safeLayout: boolean;
/** TERMIX_SAFE_MODE is set: only layout and theme plugins run. */
safeMode?: boolean;
unavailable: UnavailableLook[];
}
+1 -1
View File
@@ -237,7 +237,7 @@ export interface PluginSummary {
name: string;
version: string;
enabled: boolean;
/** enabled | disabled | blocked | failed, or the loader's live state. */
/** enabled | disabled | blocked | failed | safe-mode, or the loader's live state. */
state: string;
/** A linked desktop leaves this plugin to its server. */
runsOnServer?: boolean;
+21 -2
View File
@@ -2,6 +2,8 @@ import type React from "react";
import { cn } from "@/lib/utils";
import {
PANEL,
gridSizeStyle,
type GridSize,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
@@ -67,6 +69,8 @@ export function DataView<T>({
getKey,
renderCard,
columns,
size,
minCardWidth,
listColumns,
onRowClick,
isRowActive,
@@ -78,7 +82,12 @@ export function DataView<T>({
density?: PanelDensity;
getKey: (item: T) => string;
renderCard: (item: T) => React.ReactNode;
/** Breakpoint columns, used when no size is given. */
columns: GridColumns;
/** Columns the user picked with ViewToggle. Wins over columns. */
size?: GridSize;
/** Narrowest card in auto mode, in px. */
minCardWidth?: number;
listColumns: DataColumn<T>[];
onRowClick?: (item: T) => void;
isRowActive?: (item: T) => boolean;
@@ -93,9 +102,19 @@ export function DataView<T>({
if (view === "grid") {
return (
<div className={cn("grid", PANEL.gap, gridClass(columns), className)}>
<div
className={cn(
"grid",
PANEL.gap,
!size && gridClass(columns),
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
>
{items.map((item) => (
<div key={getKey(item)}>{renderCard(item)}</div>
<div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)}
</div>
))}
</div>
);
+82 -3
View File
@@ -1,11 +1,26 @@
/* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, isValidElement, useRef } from "react";
import type React from "react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import {
ArrowLeft,
Columns3,
Grid3X3,
List,
Rows3,
Search,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
/** One spacing rhythm for every panel and tab. */
export const PANEL = {
@@ -19,6 +34,37 @@ export const PANEL = {
export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact";
/** Cards per row. "auto" fits as many as the pane has room for. */
export type GridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
export const GRID_SIZES: readonly GridSize[] = [
"auto",
"1",
"2",
"3",
"4",
"5",
"6",
];
// Sized off the pane's own width, not the window. Cards keep a min width,
// so narrow panes drop columns on their own.
export function gridSizeStyle(
size: GridSize,
minCardWidth = 320,
gap = "0.5rem",
): React.CSSProperties {
if (size === "auto") {
return {
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
};
}
const n = Number(size);
return {
gridTemplateColumns: `repeat(auto-fill, minmax(max(min(100%, 220px), calc((100% - ${n - 1} * ${gap}) / ${n} - 0.5px)), 1fr))`,
};
}
/** The header, toolbar band and body every tab and panel draws. */
export function PanelShell({
chrome = true,
@@ -308,20 +354,25 @@ export function Segmented<T extends string>({
);
}
/** Grid or list, plus the compact rows toggle. */
/** Grid or list, plus the compact rows toggle and the cards per row picker. */
export function ViewToggle({
view,
onView,
density,
onDensity,
columns,
onColumns,
}: {
view: PanelViewMode;
onView: (next: PanelViewMode) => void;
density?: PanelDensity;
onDensity?: (next: PanelDensity) => void;
/** Cards per row in grid view. Pass with onColumns to show the picker. */
columns?: GridSize;
onColumns?: (next: GridSize) => void;
}) {
const { t } = useTranslation();
return (
const toggle = (
<div className="flex items-center border border-border">
<ToggleButton
active={view === "grid"}
@@ -356,6 +407,34 @@ export function ViewToggle({
)}
</div>
);
if (!columns || !onColumns || view !== "grid") return toggle;
return (
<div className="flex items-center gap-2">
<Select value={columns} onValueChange={(v) => onColumns(v as GridSize)}>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("panel.columns.title")}
aria-label={t("panel.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("panel.columns.auto")
: t("panel.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
{toggle}
</div>
);
}
function ToggleButton({
+12 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} is ready to install",
"readyHint": "It installs the next time Termix restarts."
},
"safeMode": {
"title": "Safe mode",
"description": "Only layout and theme plugins are running. Restart the server without TERMIX_SAFE_MODE to turn the rest back on."
},
"companion": {
"close": "Close panel",
"moveSide": "Move panel",
@@ -1311,7 +1315,8 @@
"disabled": "Disabled",
"failed": "Stopped unexpectedly",
"blocked": "Waiting on another plugin",
"server": "Runs on your server"
"server": "Runs on your server",
"safeMode": "Off in safe mode"
},
"groups": {
"running": "Running",
@@ -1325,11 +1330,6 @@
"updated": "Recently updated",
"category": "Category"
},
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
},
"table": {
"name": "Name",
"description": "Description",
@@ -1654,7 +1654,12 @@
"gridView": "Grid",
"listView": "List",
"compactRows": "Compact rows",
"comfortableRows": "Comfortable rows"
"comfortableRows": "Comfortable rows",
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
}
},
"fields": {
"secretSaved": "Saved. Type to replace it."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} is gereed om te installeer",
"readyHint": "Dit word die volgende keer wat Termix herbegin geïnstalleer."
},
"safeMode": {
"title": "Veilige modus",
"description": "Slegs uitleg- en tema-plug-ins word uitgevoer. Herbegin die bediener sonder TERMIX_SAFE_MODE om die res weer aan te skakel."
},
"companion": {
"close": "Maak paneel toe",
"moveSide": "Skuif paneel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Stel die verstek vir almal terug",
"update": "Dateer {{name}} op na {{version}}",
"disable": "Skakel {{name}} af",
"enable": "Skakel {{name}} aan",
"startAgain": "Begin {{name}} weer",
"install": "Vind lêe en temas",
"installTitle": "Lêe en temas",
"installButton": "Installeer",
@@ -1309,7 +1315,8 @@
"disabled": "Afgeskakel",
"failed": "Onverwags gestop",
"blocked": "Wag op 'n ander plugin",
"server": "Loop op jou bediener"
"server": "Loop op jou bediener",
"safeMode": "Af in veilige modus"
},
"groups": {
"running": "Aktief",
@@ -1323,11 +1330,6 @@
"updated": "Onlangs opgedateer",
"category": "Kategorie"
},
"columns": {
"title": "Kolomme",
"auto": "Outo",
"count": "{{count}} breed"
},
"table": {
"name": "Naam",
"description": "Beskrywing",
@@ -1652,7 +1654,12 @@
"gridView": "Rooster",
"listView": "Lys",
"compactRows": "Kompakte rye",
"comfortableRows": "Gerieflike rye"
"comfortableRows": "Gerieflike rye",
"columns": {
"title": "Kolomme",
"auto": "Outo",
"count": "{{count}} breed"
}
},
"fields": {
"secretSaved": "Opgeslaan. Tik om dit te vervang."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} جاهز للتثبيت",
"readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix."
},
"safeMode": {
"title": "الوضع الآمن",
"description": "لا تعمل سوى إضافات التخطيط والمظهر. أعد تشغيل الخادم بدون TERMIX_SAFE_MODE لإعادة تشغيل الباقي."
},
"companion": {
"close": "إغلاق اللوحة",
"moveSide": "نقل اللوحة",
@@ -1166,6 +1170,8 @@
"resetEveryone": "إعادة تعيين الافتراضي للجميع",
"update": "تحديث {{name}} إلى {{version}}",
"disable": "إيقاف {{name}}",
"enable": "تفعيل {{name}}",
"startAgain": "إعادة تشغيل {{name}}",
"install": "البحث عن التخطيطات والسمات",
"installTitle": "التخطيطات والسمات",
"installButton": "تثبيت",
@@ -1377,7 +1383,8 @@
"disabled": "معطلة",
"failed": "توقفت بشكل غير متوقع",
"blocked": "في انتظار إضافة أخرى",
"server": "يعمل على خادمك"
"server": "يعمل على خادمك",
"safeMode": "معطل في الوضع الآمن"
},
"groups": {
"running": "تعمل",
@@ -1391,11 +1398,6 @@
"updated": "تم تحديثها مؤخراً",
"category": "الفئة"
},
"columns": {
"title": "الأعمدة",
"auto": "تلقائي",
"count": "عرض {{count}}"
},
"table": {
"name": "الاسم",
"description": "الوصف",
@@ -1820,7 +1822,12 @@
"gridView": "شبكة",
"listView": "قائمة",
"compactRows": "صفوف مدمجة",
"comfortableRows": "صفوف مريحة"
"comfortableRows": "صفوف مريحة",
"columns": {
"title": "الأعمدة",
"auto": "تلقائي",
"count": "عرض {{count}}"
}
},
"fields": {
"secretSaved": "تم الحفظ. اكتب لاستبداله."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} е готов за инсталация",
"readyHint": "Ще се инсталира следващия път, когато Termix се рестартира."
},
"safeMode": {
"title": "Режим на безопасност",
"description": "Само плъгините за подредба и тема са активни. Рестартирайте сървъра без променливата TERMIX_SAFE_MODE, за да активирате останалите."
},
"companion": {
"close": "Затвори панела",
"moveSide": "Премести панела",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Върни настройките по подразбиране за всички",
"update": "Актуализирай {{name}} до {{version}}",
"disable": "Изключи {{name}}",
"enable": "Активиране на {{name}}",
"startAgain": "Стартиране на {{name}} отново",
"install": "Намери подредби и теми",
"installTitle": "Подредби и теми",
"installButton": "Инсталирай",
@@ -1309,7 +1315,8 @@
"disabled": "Изключена",
"failed": "Спряна неочаквано",
"blocked": "Чака друга добавка",
"server": "Работи на вашия сървър"
"server": "Работи на вашия сървър",
"safeMode": "Изключен в режим на безопасност"
},
"groups": {
"running": "Работещи",
@@ -1323,11 +1330,6 @@
"updated": "Наскоро актуализирани",
"category": "Категория"
},
"columns": {
"title": "Колони",
"auto": "Автоматично",
"count": "{{count}} колони"
},
"table": {
"name": "Име",
"description": "Описание",
@@ -1652,7 +1654,12 @@
"gridView": "Мрежа",
"listView": "Списък",
"compactRows": "Компактни редове",
"comfortableRows": "Комфортни редове"
"comfortableRows": "Комфортни редове",
"columns": {
"title": "Колони",
"auto": "Автоматично",
"count": "ширина {{count}}"
}
},
"fields": {
"secretSaved": "Запазено. Въведете ново, за да го замените."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} ইনস্টল করার জন্য প্রস্তুত",
"readyHint": "এটি পরবর্তীবার Termix রিস্টার্ট করলে ইনস্টল হবে।"
},
"safeMode": {
"title": "সেফ মোড",
"description": "শুধুমাত্র লেআউট এবং থিম প্লাগইন চলছে। বাকিগুলো আবার চালু করতে TERMIX_SAFE_MODE ছাড়া সার্ভারটি রিস্টার্ট করুন।"
},
"companion": {
"close": "প্যানেল বন্ধ করুন",
"moveSide": "প্যানেল সরান",
@@ -1098,6 +1102,8 @@
"resetEveryone": "সবার জন্য ডিফল্ট রিসেট করুন",
"update": "{{name}} কে {{version}} এ আপডেট করুন",
"disable": "{{name}} বন্ধ করুন",
"enable": "{{name}} চালু করুন",
"startAgain": "{{name}} আবার শুরু করুন",
"install": "লেআউট এবং থিম খুঁজুন",
"installTitle": "লেআউট এবং থিম",
"installButton": "ইনস্টল করুন",
@@ -1309,7 +1315,8 @@
"disabled": "নিষ্ক্রিয়",
"failed": "অপ্রত্যাশিতভাবে থেমে গেছে",
"blocked": "অন্য প্লাগইনের জন্য অপেক্ষা করছে",
"server": "আপনার সার্ভারে চলে"
"server": "আপনার সার্ভারে চলে",
"safeMode": "সেফ মোডে বন্ধ"
},
"groups": {
"running": "চলমান",
@@ -1323,11 +1330,6 @@
"updated": "সাম্প্রতিক আপডেট",
"category": "ক্যাটাগরি"
},
"columns": {
"title": "কলাম",
"auto": "স্বয়ংক্রিয়",
"count": "{{count}} প্রশস্ত"
},
"table": {
"name": "নাম",
"description": "বিবরণ",
@@ -1652,7 +1654,12 @@
"gridView": "গ্রিড",
"listView": "তালিকা",
"compactRows": "কমপ্যাক্ট সারি",
"comfortableRows": "আরামদায়ক সারি"
"comfortableRows": "আরামদায়ক সারি",
"columns": {
"title": "কলাম",
"auto": "স্বয়ংক্রিয়",
"count": "{{count}} প্রশস্ত"
}
},
"fields": {
"secretSaved": "সংরক্ষিত হয়েছে। এটি প্রতিস্থাপন করতে টাইপ করুন।"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} està llest per instal·lar-se",
"readyHint": "S'instal·larà la propera vegada que reiniciïs Termix."
},
"safeMode": {
"title": "Mode segur",
"description": "Només s'estan executant els complements de disseny i tema. Reinicia el servidor sense TERMIX_SAFE_MODE per activar la resta."
},
"companion": {
"close": "Tanca el panell",
"moveSide": "Mou el panell",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Restableix el predeterminat per a tothom",
"update": "Actualitza {{name}} a {{version}}",
"disable": "Desactiva {{name}}",
"enable": "Activar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Cerca dissenys i temes",
"installTitle": "Disseny i temes",
"installButton": "Instal·la",
@@ -1326,7 +1332,8 @@
"disabled": "Desactivat",
"failed": "Aturat inesperadament",
"blocked": "Esperant un altre plugin",
"server": "S'executa al teu servidor"
"server": "S'executa al teu servidor",
"safeMode": "Desactivat en mode segur"
},
"groups": {
"running": "En execució",
@@ -1340,11 +1347,6 @@
"updated": "Actualitzats recentment",
"category": "Categoria"
},
"columns": {
"title": "Columnes",
"auto": "Automàtic",
"count": "{{count}} d'amplada"
},
"table": {
"name": "Nom",
"description": "Descripció",
@@ -1694,7 +1696,12 @@
"gridView": "Graella",
"listView": "Llista",
"compactRows": "Files compactes",
"comfortableRows": "Files còmodes"
"comfortableRows": "Files còmodes",
"columns": {
"title": "Columnes",
"auto": "Automàtic",
"count": "{{count}} d'ample"
}
},
"fields": {
"secretSaved": "Desat. Escriu per substituir-lo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} je připraven k instalaci",
"readyHint": "Nainstaluje se při příštím restartu Termixu."
},
"safeMode": {
"title": "Režim nouzového spuštění",
"description": "Spouštějí se pouze zásuvné moduly rozložení a motivu. Restartujte server bez proměnné TERMIX_SAFE_MODE, abyste znovu povolili zbytek."
},
"companion": {
"close": "Zavřít panel",
"moveSide": "Přesunout panel",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Obnovit výchozí nastavení pro všechny",
"update": "Aktualizovat {{name}} na {{version}}",
"disable": "Vypnout {{name}}",
"enable": "Zapnout {{name}}",
"startAgain": "Spustit {{name}} znovu",
"install": "Hledat rozložení a motivy",
"installTitle": "Rozložení a motivy",
"installButton": "Instalovat",
@@ -1343,7 +1349,8 @@
"disabled": "Zakázáno",
"failed": "Neočekávaně zastaveno",
"blocked": "Čeká na jiný plugin",
"server": "Běží na vašem serveru"
"server": "Běží na vašem serveru",
"safeMode": "Vypnuto v režimu nouzového spuštění"
},
"groups": {
"running": "Spuštěné",
@@ -1357,11 +1364,6 @@
"updated": "Nedávno aktualizované",
"category": "Kategorie"
},
"columns": {
"title": "Sloupce",
"auto": "Automaticky",
"count": "Šířka {{count}}"
},
"table": {
"name": "Název",
"description": "Popis",
@@ -1736,7 +1738,12 @@
"gridView": "Mřížka",
"listView": "Seznam",
"compactRows": "Kompaktní řádky",
"comfortableRows": "Pohodlné řádky"
"comfortableRows": "Pohodlné řádky",
"columns": {
"title": "Sloupce",
"auto": "Automaticky",
"count": "šířka {{count}}"
}
},
"fields": {
"secretSaved": "Uloženo. Napište pro nahrazení."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} er klar til installation",
"readyHint": "Den installeres næste gang Termix genstartes."
},
"safeMode": {
"title": "Sikker tilstand",
"description": "Kun layout- og tema-plugins kører. Genstart serveren uden TERMIX_SAFE_MODE for at aktivere de øvrige plugins igen."
},
"companion": {
"close": "Luk panel",
"moveSide": "Flyt panel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Nulstil standardindstillingen for alle",
"update": "Opdater {{name}} til {{version}}",
"disable": "Sluk {{name}}",
"enable": "Aktivér {{name}}",
"startAgain": "Start {{name}} igen",
"install": "Find layouts og temaer",
"installTitle": "Layouts og temaer",
"installButton": "Installer",
@@ -1309,7 +1315,8 @@
"disabled": "Deaktiveret",
"failed": "Stoppet uventet",
"blocked": "Venter på et andet plugin",
"server": "Kører på din server"
"server": "Kører på din server",
"safeMode": "Deaktiveret i sikker tilstand"
},
"groups": {
"running": "Kører",
@@ -1323,11 +1330,6 @@
"updated": "Nyligt opdateret",
"category": "Kategori"
},
"columns": {
"title": "Kolonner",
"auto": "Auto",
"count": "{{count}} bred"
},
"table": {
"name": "Navn",
"description": "Beskrivelse",
@@ -1652,7 +1654,12 @@
"gridView": "Gitter",
"listView": "Liste",
"compactRows": "Kompakte rækker",
"comfortableRows": "Komfortable rækker"
"comfortableRows": "Komfortable rækker",
"columns": {
"title": "Søjler",
"auto": "Automatisk",
"count": "{{count}} bred"
}
},
"fields": {
"secretSaved": "Gemt. Skriv for at erstatte det."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} ist bereit zur Installation",
"readyHint": "Es wird beim nächsten Neustart von Termix installiert."
},
"safeMode": {
"title": "Sicherer Modus",
"description": "Nur Layout- und Themen-Plugins sind aktiv. Starten Sie den Server ohne TERMIX_SAFE_MODE neu, um die restlichen Plugins wieder zu aktivieren."
},
"companion": {
"close": "Panel schließen",
"moveSide": "Panel verschieben",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Standard für alle zurücksetzen",
"update": "{{name}} auf {{version}} aktualisieren",
"disable": "{{name}} deaktivieren",
"enable": "{{name}} aktivieren",
"startAgain": "{{name}} neu starten",
"install": "Layouts und Themes finden",
"installTitle": "Layouts und Themes",
"installButton": "Installieren",
@@ -1309,7 +1315,8 @@
"disabled": "Deaktiviert",
"failed": "Unerwartet gestoppt",
"blocked": "Wartet auf ein anderes Plugin",
"server": "Läuft auf deinem Server"
"server": "Läuft auf deinem Server",
"safeMode": "Im sicheren Modus deaktiviert"
},
"groups": {
"running": "Aktiv",
@@ -1323,11 +1330,6 @@
"updated": "Kürzlich aktualisiert",
"category": "Kategorie"
},
"columns": {
"title": "Spalten",
"auto": "Auto",
"count": "{{count}} Spalten breit"
},
"table": {
"name": "Name",
"description": "Beschreibung",
@@ -1652,7 +1654,12 @@
"gridView": "Raster",
"listView": "Liste",
"compactRows": "Kompakte Zeilen",
"comfortableRows": "Bequeme Zeilen"
"comfortableRows": "Bequeme Zeilen",
"columns": {
"title": "Spalten",
"auto": "Automatisch",
"count": "{{count}} Spalten breit"
}
},
"fields": {
"secretSaved": "Gespeichert. Tippen, um es zu ersetzen."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Το Termix {{version}} είναι έτοιμο για εγκατάσταση",
"readyHint": "Εγκαθίσταται την επόμενη φορά που θα επανεκκινήσει το Termix."
},
"safeMode": {
"title": "Λειτουργία ασφαλείας",
"description": "Τρέχουν μόνο οι προσθήκες διάταξης και θέματος. Επανεκκινήστε τον διακομιστή χωρίς το TERMIX_SAFE_MODE για να ενεργοποιήσετε ξανά τις υπόλοιπες."
},
"companion": {
"close": "Κλείσιμο πάνελ",
"moveSide": "Μετακίνηση πάνελ",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Επαναφορά της προεπιλογής για όλους",
"update": "Ενημέρωση του {{name}} στην έκδοση {{version}}",
"disable": "Απενεργοποίηση του {{name}}",
"enable": "Ενεργοποίηση {{name}}",
"startAgain": "Εκκίνηση {{name}} ξανά",
"install": "Εύρεση layouts και θεμάτων",
"installTitle": "Layouts και θέματα",
"installButton": "Εγκατάσταση",
@@ -1309,7 +1315,8 @@
"disabled": "Απενεργοποιημένο",
"failed": "Διακόπηκε απροσδόκητα",
"blocked": "Περιμένει άλλο plugin",
"server": "Τρέχει στον διακομιστή σας"
"server": "Τρέχει στον διακομιστή σας",
"safeMode": "Ανενεργό στη λειτουργία ασφαλείας"
},
"groups": {
"running": "Εν ενεργεία",
@@ -1323,11 +1330,6 @@
"updated": "Πρόσφατα ενημερωμένα",
"category": "Κατηγορία"
},
"columns": {
"title": "Στήλες",
"auto": "Αυτόματο",
"count": "{{count}} στήλες"
},
"table": {
"name": "Όνομα",
"description": "Περιγραφή",
@@ -1652,7 +1654,12 @@
"gridView": "Πλέγμα",
"listView": "Λίστα",
"compactRows": "Συμπαγείς γραμμές",
"comfortableRows": "Άνετες γραμμές"
"comfortableRows": "Άνετες γραμμές",
"columns": {
"title": "Στήλες",
"auto": "Αυτόματο",
"count": "Πλάτος {{count}}"
}
},
"fields": {
"secretSaved": "Αποθηκεύτηκε. Πληκτρολογήστε για να το αντικαταστήσετε."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} está listo para instalar",
"readyHint": "Se instalará la próxima vez que reinicies Termix."
},
"safeMode": {
"title": "Modo seguro",
"description": "Solo se están ejecutando los complementos de diseño y tema. Reinicia el servidor sin TERMIX_SAFE_MODE para volver a activar el resto."
},
"companion": {
"close": "Cerrar panel",
"moveSide": "Mover panel",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Restablecer el valor predeterminado para todos",
"update": "Actualizar {{name}} a {{version}}",
"disable": "Desactivar {{name}}",
"enable": "Activar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Buscar diseños y temas",
"installTitle": "Diseños y temas",
"installButton": "Instalar",
@@ -1326,7 +1332,8 @@
"disabled": "Desactivado",
"failed": "Detenido inesperadamente",
"blocked": "Esperando a otro complemento",
"server": "Se ejecuta en tu servidor"
"server": "Se ejecuta en tu servidor",
"safeMode": "Desactivado en modo seguro"
},
"groups": {
"running": "En ejecución",
@@ -1340,11 +1347,6 @@
"updated": "Actualizados recientemente",
"category": "Categoría"
},
"columns": {
"title": "Columnas",
"auto": "Automático",
"count": "{{count}} de ancho"
},
"table": {
"name": "Nombre",
"description": "Descripción",
@@ -1694,7 +1696,12 @@
"gridView": "Cuadrícula",
"listView": "Lista",
"compactRows": "Filas compactas",
"comfortableRows": "Filas cómodas"
"comfortableRows": "Filas cómodas",
"columns": {
"title": "Columnas",
"auto": "Automático",
"count": "{{count}} de ancho"
}
},
"fields": {
"secretSaved": "Guardado. Escribe para reemplazarlo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} on paigaldamiseks valmis",
"readyHint": "See paigaldatakse järgmisel Termixi taaskäivitamisel."
},
"safeMode": {
"title": "Turvarežiim",
"description": "Töötavad ainult paigutuse ja teema lisad. Taaskäivitage server ilma TERMIX_SAFE_MODE määramata, et ülejäänud lisad uuesti lubada."
},
"companion": {
"close": "Sulge paneel",
"moveSide": "Liiguta paneeli",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Taasta vaikimisi seadistus kõigile",
"update": "Uuenda {{name}} versioonile {{version}}",
"disable": "Lülita {{name}} välja",
"enable": "Lülita {{name}} sisse",
"startAgain": "Käivita {{name}} uuesti",
"install": "Otsi paigutusi ja teemasid",
"installTitle": "Paigutused ja teemad",
"installButton": "Paigalda",
@@ -1309,7 +1315,8 @@
"disabled": "Keelatud",
"failed": "Peatati ootamatult",
"blocked": "Ootab teist pistikprogrammi",
"server": "Töötab sinu serveris"
"server": "Töötab sinu serveris",
"safeMode": "Turvarežiimis välja lülitatud"
},
"groups": {
"running": "Käimas",
@@ -1323,11 +1330,6 @@
"updated": "Hiljuti uuendatud",
"category": "Kategooria"
},
"columns": {
"title": "Veerud",
"auto": "Automaatne",
"count": "{{count}} lai"
},
"table": {
"name": "Nimi",
"description": "Kirjeldus",
@@ -1652,7 +1654,12 @@
"gridView": "Ruudustik",
"listView": "Loend",
"compactRows": "Kompaktsead read",
"comfortableRows": "Mugavad read"
"comfortableRows": "Mugavad read",
"columns": {
"title": "Veerud",
"auto": "Automaatne",
"count": "{{count}} veeru laiune"
}
},
"fields": {
"secretSaved": "Salvestatud. Tüüpi, et asendada."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} on valmis asennettavaksi",
"readyHint": "Se asennetaan seuraavan kerran, kun Termix käynnistetään uudelleen."
},
"safeMode": {
"title": "Turvatoiminto",
"description": "Vain asettelun ja teeman lisäosat ovat käytössä. Käynnistä palvelin uudelleen ilman TERMIX_SAFE_MODE-muuttujaa ottaaksesi muut lisäosat takaisin käyttöön."
},
"companion": {
"close": "Sulje paneeli",
"moveSide": "Siirrä paneeli",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Palauta oletus kaikille",
"update": "Päivitä {{name}} versioon {{version}}",
"disable": "Sammuta {{name}}",
"enable": "Ota {{name}} käyttöön",
"startAgain": "Käynnistä {{name}} uudelleen",
"install": "Etsi asetteluja ja teemoja",
"installTitle": "Asettelut ja teemat",
"installButton": "Asenna",
@@ -1309,7 +1315,8 @@
"disabled": "Poistettu käytöstä",
"failed": "Pysähtyi odottamatta",
"blocked": "Odottaa toista lisäosaa",
"server": "Käynnistyy palvelimellasi"
"server": "Käynnistyy palvelimellasi",
"safeMode": "Pois käytöstä turvatoiminnossa"
},
"groups": {
"running": "Käynnissä",
@@ -1323,11 +1330,6 @@
"updated": "Viimeksi päivitetty",
"category": "Kategoria"
},
"columns": {
"title": "Sarakkeet",
"auto": "Automaattinen",
"count": "{{count}} saraketta leveä"
},
"table": {
"name": "Nimi",
"description": "Kuvaus",
@@ -1652,7 +1654,12 @@
"gridView": "Ruudukko",
"listView": "Lista",
"compactRows": "Tiiviit rivit",
"comfortableRows": "Mukavat rivit"
"comfortableRows": "Mukavat rivit",
"columns": {
"title": "Sarakkeet",
"auto": "Automaattinen",
"count": "{{count}} saraketta leveä"
}
},
"fields": {
"secretSaved": "Tallennettu. Kirjoita korvataksesi sen."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} est prêt à être installé",
"readyHint": "Il sera installé au prochain redémarrage de Termix."
},
"safeMode": {
"title": "Mode sans échec",
"description": "Seuls les plugins de mise en page et de thème sont actifs. Redémarrez le serveur sans la variable TERMIX_SAFE_MODE pour réactiver les autres."
},
"companion": {
"close": "Fermer le panneau",
"moveSide": "Déplacer le panneau",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Réinitialiser la valeur par défaut pour tout le monde",
"update": "Mettre à jour {{name}} vers {{version}}",
"disable": "Désactiver {{name}}",
"enable": "Activer {{name}}",
"startAgain": "Relancer {{name}}",
"install": "Trouver des dispositions et des thèmes",
"installTitle": "Dispositions et thèmes",
"installButton": "Installer",
@@ -1326,7 +1332,8 @@
"disabled": "Désactivé",
"failed": "Arrêt inattendu",
"blocked": "En attente d'un autre plugin",
"server": "Exécute sur votre serveur"
"server": "Exécute sur votre serveur",
"safeMode": "Désactivé en mode sans échec"
},
"groups": {
"running": "En cours d'exécution",
@@ -1340,11 +1347,6 @@
"updated": "Récemment mis à jour",
"category": "Catégorie"
},
"columns": {
"title": "Colonnes",
"auto": "Auto",
"count": "{{count}} de large"
},
"table": {
"name": "Nom",
"description": "Description",
@@ -1694,7 +1696,12 @@
"gridView": "Grille",
"listView": "Liste",
"compactRows": "Lignes compactes",
"comfortableRows": "Lignes confortables"
"comfortableRows": "Lignes confortables",
"columns": {
"title": "Colonnes",
"auto": "Auto",
"count": "{{count}} de large"
}
},
"fields": {
"secretSaved": "Enregistré. Tapez pour le remplacer."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} מוכן להתקנה",
"readyHint": "הוא מותקן בפעם הבאה ש-Termix מופעל מחדש."
},
"safeMode": {
"title": "מצב בטוח",
"description": "רק תוספי פריסה ועיצוב פועלים. הפעל מחדש את השרת ללא המשתנה TERMIX_SAFE_MODE כדי להפעיל מחדש את שאר התוספים."
},
"companion": {
"close": "סגור לוח",
"moveSide": "הזז לוח",
@@ -1115,6 +1119,8 @@
"resetEveryone": "אפס את ברירת המחדל לכולם",
"update": "עדכן את {{name}} ל-{{version}}",
"disable": "כבה את {{name}}",
"enable": "הפעל את {{name}}",
"startAgain": "הפעל מחדש את {{name}}",
"install": "חפש פריסות וערכות נושא",
"installTitle": "פריסות וערכות נושא",
"installButton": "התקן",
@@ -1326,7 +1332,8 @@
"disabled": "מושבת",
"failed": "הופסק ללא ציפייה",
"blocked": "ממתין לתוסף אחר",
"server": "רץ על השרת שלך"
"server": "רץ על השרת שלך",
"safeMode": "כבוי במצב בטוח"
},
"groups": {
"running": "פעילים",
@@ -1340,11 +1347,6 @@
"updated": "עודכן לאחרונה",
"category": "קטגוריה"
},
"columns": {
"title": "עמודות",
"auto": "אוטומטי",
"count": "רוחב של {{count}}"
},
"table": {
"name": "שם",
"description": "תיאור",
@@ -1694,7 +1696,12 @@
"gridView": "רשת",
"listView": "רשימה",
"compactRows": "שורות צפופות",
"comfortableRows": "שורות נוחות"
"comfortableRows": "שורות נוחות",
"columns": {
"title": "עמודות",
"auto": "אוטומטי",
"count": "רוחב של {{count}}"
}
},
"fields": {
"secretSaved": "נשמר. הקלד כדי להחליף אותו."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} इंस्टॉल करने के लिए तैयार है",
"readyHint": "यह अगली बार जब Termix पुनरारंभ होगा तब इंस्टॉल होगा।"
},
"safeMode": {
"title": "सुरक्षित मोड",
"description": "केवल लेआउट और थीम प्लगइन चल रहे हैं। बाकी सब कुछ वापस चालू करने के लिए TERMIX_SAFE_MODE के बिना सर्वर को पुनः आरंभ करें।"
},
"companion": {
"close": "पैनल बंद करें",
"moveSide": "पैनल स्थानांतरित करें",
@@ -1098,6 +1102,8 @@
"resetEveryone": "सभी के लिए डिफ़ॉल्ट रीसेट करें",
"update": "{{name}} को {{version}} पर अपडेट करें",
"disable": "{{name}} बंद करें",
"enable": "{{name}} चालू करें",
"startAgain": "{{name}} को फिर से शुरू करें",
"install": "लेआउट और थीम खोजें",
"installTitle": "लेआउट और थीम",
"installButton": "इंस्टॉल करें",
@@ -1309,7 +1315,8 @@
"disabled": "अक्षम",
"failed": "अप्रत्याशित रूप से रुका",
"blocked": "दूसरे प्लगइन का इंतज़ार कर रहा है",
"server": "आपके सर्वर पर चलता है"
"server": "आपके सर्वर पर चलता है",
"safeMode": "सुरक्षित मोड में बंद"
},
"groups": {
"running": "चल रहे",
@@ -1323,11 +1330,6 @@
"updated": "हाल ही में अपडेट किया गया",
"category": "श्रेणी"
},
"columns": {
"title": "कॉलम",
"auto": "स्वचालित",
"count": "{{count}} चौड़ा"
},
"table": {
"name": "नाम",
"description": "विवरण",
@@ -1652,7 +1654,12 @@
"gridView": "ग्रिड",
"listView": "सूची",
"compactRows": "संक्षिप्त पंक्तियाँ",
"comfortableRows": "आरामदायक पंक्तियाँ"
"comfortableRows": "आरामदायक पंक्तियाँ",
"columns": {
"title": "कॉलम",
"auto": "स्वचालित",
"count": "{{count}} चौड़ा"
}
},
"fields": {
"secretSaved": "सहेजा गया। इसे बदलने के लिए टाइप करें।"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} je spreman za instalaciju",
"readyHint": "Instalirat će se sljedeći put kada se Termix ponovno pokrene."
},
"safeMode": {
"title": "Sigurni način rada",
"description": "Pokreću se samo dodaci za raspored i temu. Ponovno pokrenite poslužitelj bez postavke TERMIX_SAFE_MODE da biste ponovno omogućili ostale dodatke."
},
"companion": {
"close": "Zatvori panel",
"moveSide": "Pomakni panel",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Vrati zadano za sve",
"update": "Ažuriraj {{name}} na {{version}}",
"disable": "Isključi {{name}}",
"enable": "Uključi {{name}}",
"startAgain": "Ponovno pokreni {{name}}",
"install": "Pronađi rasporede i teme",
"installTitle": "Rasporedi i teme",
"installButton": "Instaliraj",
@@ -1326,7 +1332,8 @@
"disabled": "Onemogućeno",
"failed": "Neočekivano zaustavljeno",
"blocked": "Čeka drugi dodatak",
"server": "Radi na vašem poslužitelju"
"server": "Radi na vašem poslužitelju",
"safeMode": "Isključeno u sigurnom načinu rada"
},
"groups": {
"running": "Pokrenuto",
@@ -1340,11 +1347,6 @@
"updated": "Nedavno ažurirano",
"category": "Kategorija"
},
"columns": {
"title": "Stupci",
"auto": "Automatski",
"count": "{{count}} široko"
},
"table": {
"name": "Naziv",
"description": "Opis",
@@ -1694,7 +1696,12 @@
"gridView": "Mreža",
"listView": "Popis",
"compactRows": "Kompaktni redovi",
"comfortableRows": "Prostrani redovi"
"comfortableRows": "Prostrani redovi",
"columns": {
"title": "Stupci",
"auto": "Automatski",
"count": "Širina: {{count}}"
}
},
"fields": {
"secretSaved": "Spremljeno. Upišite za zamjenu."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "A Termix {{version}} verziója készen áll a telepítésre",
"readyHint": "A következő újraindításkor települ."
},
"safeMode": {
"title": "Biztonságos mód",
"description": "Csak a elrendezés- és témabővítmények futnak. Indítsa újra a szervert a TERMIX_SAFE_MODE nélkül, hogy a többit is újra bekapcsolja."
},
"companion": {
"close": "Panel bezárása",
"moveSide": "Panel áthelyezése",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Az alapértelmezett visszaállítása mindenkinek",
"update": "{{name}} frissítése a {{version}} verzióra",
"disable": "{{name}} kikapcsolása",
"enable": "{{name}} bekapcsolása",
"startAgain": "{{name}} újraindítása",
"install": "Elrendezések és témák keresése",
"installTitle": "Elrendezések és témák",
"installButton": "Telepítés",
@@ -1309,7 +1315,8 @@
"disabled": "Letiltva",
"failed": "Váratlanul leállt",
"blocked": "Várakozás egy másik bővítményre",
"server": "A szerverén fut"
"server": "A szerverén fut",
"safeMode": "Kikapcsolva biztonságos módban"
},
"groups": {
"running": "Fut",
@@ -1323,11 +1330,6 @@
"updated": "Legutóbb frissítve",
"category": "Kategória"
},
"columns": {
"title": "Oszlopok",
"auto": "Automatikus",
"count": "{{count}} oszlop széles"
},
"table": {
"name": "Név",
"description": "Leírás",
@@ -1652,7 +1654,12 @@
"gridView": "Rács",
"listView": "Lista",
"compactRows": "Kompakt sorok",
"comfortableRows": "Kényelmes sorok"
"comfortableRows": "Kényelmes sorok",
"columns": {
"title": "Oszlopok",
"auto": "Automatikus",
"count": "{{count}} oszlop széles"
}
},
"fields": {
"secretSaved": "Mentve. Írjon be új értéket a helyettesítéshez."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} siap untuk diinstal",
"readyHint": "Ini akan diinstal saat Termix dimulai ulang berikutnya."
},
"safeMode": {
"title": "Mode aman",
"description": "Hanya plugin tata letak dan tema yang berjalan. Mulai ulang server tanpa TERMIX_SAFE_MODE untuk mengaktifkan kembali yang lainnya."
},
"companion": {
"close": "Tutup panel",
"moveSide": "Pindahkan panel",
@@ -1081,6 +1085,8 @@
"resetEveryone": "Atur ulang default untuk semua orang",
"update": "Perbarui {{name}} ke {{version}}",
"disable": "Matikan {{name}}",
"enable": "Aktifkan {{name}}",
"startAgain": "Mulai ulang {{name}}",
"install": "Cari tata letak dan tema",
"installTitle": "Tata letak dan tema",
"installButton": "Instal",
@@ -1292,7 +1298,8 @@
"disabled": "Dinonaktifkan",
"failed": "Berhenti secara tak terduga",
"blocked": "Menunggu plugin lain",
"server": "Berjalan di server Anda"
"server": "Berjalan di server Anda",
"safeMode": "Nonaktif di mode aman"
},
"groups": {
"running": "Berjalan",
@@ -1306,11 +1313,6 @@
"updated": "Baru diperbarui",
"category": "Kategori"
},
"columns": {
"title": "Kolom",
"auto": "Otomatis",
"count": "Lebar {{count}}"
},
"table": {
"name": "Nama",
"description": "Deskripsi",
@@ -1610,7 +1612,12 @@
"gridView": "Grid",
"listView": "Daftar",
"compactRows": "Baris ringkas",
"comfortableRows": "Baris nyaman"
"comfortableRows": "Baris nyaman",
"columns": {
"title": "Kolom",
"auto": "Otomatis",
"count": "Lebar {{count}}"
}
},
"fields": {
"secretSaved": "Tersimpan. Ketik untuk menggantinya."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} è pronto per l'installazione",
"readyHint": "Verrà installato al prossimo riavvio di Termix."
},
"safeMode": {
"title": "Modalità sicura",
"description": "Sono attivi solo i plugin per layout e temi. Riavvia il server senza TERMIX_SAFE_MODE per riattivare gli altri."
},
"companion": {
"close": "Chiudi pannello",
"moveSide": "Sposta pannello",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Reimposta il predefinito per tutti",
"update": "Aggiorna {{name}} a {{version}}",
"disable": "Disattiva {{name}}",
"enable": "Attiva {{name}}",
"startAgain": "Riavvia {{name}}",
"install": "Trova layout e temi",
"installTitle": "Layout e temi",
"installButton": "Installa",
@@ -1326,7 +1332,8 @@
"disabled": "Disabilitato",
"failed": "Arrestato inaspettatamente",
"blocked": "In attesa di un altro plugin",
"server": "Eseguito sul tuo server"
"server": "Eseguito sul tuo server",
"safeMode": "Disattivato in modalità sicura"
},
"groups": {
"running": "In esecuzione",
@@ -1340,11 +1347,6 @@
"updated": "Aggiornati di recente",
"category": "Categoria"
},
"columns": {
"title": "Colonne",
"auto": "Automatico",
"count": "Larghezza {{count}}"
},
"table": {
"name": "Nome",
"description": "Descrizione",
@@ -1694,7 +1696,12 @@
"gridView": "Griglia",
"listView": "Elenco",
"compactRows": "Righe compatte",
"comfortableRows": "Righe spaziose"
"comfortableRows": "Righe spaziose",
"columns": {
"title": "Colonne",
"auto": "Automatico",
"count": "{{count}} colonne"
}
},
"fields": {
"secretSaved": "Salvato. Digita per sostituirlo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} のインストール準備ができました",
"readyHint": "Termix を再起動したときにインストールされます。"
},
"safeMode": {
"title": "セーフモード",
"description": "レイアウトとテーマのプラグインのみが実行されています。TERMIX_SAFE_MODE を設定せずにサーバーを再起動すると、他のプラグインが再度有効になります。"
},
"companion": {
"close": "パネルを閉じる",
"moveSide": "パネルを移動",
@@ -1081,6 +1085,8 @@
"resetEveryone": "すべてのユーザーのデフォルトをリセット",
"update": "{{name}} を {{version}} に更新",
"disable": "{{name}} を無効にする",
"enable": "{{name}} を有効にする",
"startAgain": "{{name}} を再起動する",
"install": "レイアウトとテーマを探す",
"installTitle": "レイアウトとテーマ",
"installButton": "インストール",
@@ -1292,7 +1298,8 @@
"disabled": "無効",
"failed": "予期せず停止",
"blocked": "他のプラグインを待機中",
"server": "サーバー上で実行"
"server": "サーバー上で実行",
"safeMode": "セーフモードで無効"
},
"groups": {
"running": "実行中",
@@ -1306,11 +1313,6 @@
"updated": "最近更新",
"category": "カテゴリ"
},
"columns": {
"title": "列",
"auto": "自動",
"count": "{{count}} 列"
},
"table": {
"name": "名前",
"description": "説明",
@@ -1610,7 +1612,12 @@
"gridView": "グリッド",
"listView": "リスト",
"compactRows": "コンパクトな行",
"comfortableRows": "ゆったりした行"
"comfortableRows": "ゆったりした行",
"columns": {
"title": "カラム",
"auto": "自動",
"count": "{{count}} カラム"
}
},
"fields": {
"secretSaved": "保存しました。入力して置き換えてください。"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} 설치 준비 완료",
"readyHint": "Termix가 다음에 재시작될 때 설치됩니다."
},
"safeMode": {
"title": "안전 모드",
"description": "레이아웃 및 테마 플러그인만 실행 중입니다. TERMIX_SAFE_MODE 없이 서버를 다시 시작하여 나머지 기능을 다시 활성화하세요."
},
"companion": {
"close": "패널 닫기",
"moveSide": "패널 이동",
@@ -1081,6 +1085,8 @@
"resetEveryone": "모두의 기본값 초기화",
"update": "{{name}}을(를) {{version}}(으)로 업데이트",
"disable": "{{name}} 끄기",
"enable": "{{name}} 켜기",
"startAgain": "{{name}} 다시 시작",
"install": "레이아웃 및 테마 찾기",
"installTitle": "레이아웃 및 테마",
"installButton": "설치",
@@ -1292,7 +1298,8 @@
"disabled": "비활성화됨",
"failed": "예기치 않게 중지됨",
"blocked": "다른 플러그인 대기 중",
"server": "서버에서 실행 중"
"server": "서버에서 실행 중",
"safeMode": "안전 모드에서 꺼짐"
},
"groups": {
"running": "실행 중",
@@ -1306,11 +1313,6 @@
"updated": "최근 업데이트순",
"category": "카테고리순"
},
"columns": {
"title": "열",
"auto": "자동",
"count": "{{count}}열 너비"
},
"table": {
"name": "이름",
"description": "설명",
@@ -1610,7 +1612,12 @@
"gridView": "그리드",
"listView": "목록",
"compactRows": "간결한 행",
"comfortableRows": "넓은 행"
"comfortableRows": "넓은 행",
"columns": {
"title": "열",
"auto": "자동",
"count": "{{count}}열 너비"
}
},
"fields": {
"secretSaved": "저장되었습니다. 입력하여 교체하세요."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} paruošta įdiegimui",
"readyHint": "Ji bus įdiegta kitą kartą paleidus Termix iš naujo."
},
"safeMode": {
"title": "Saugus režimas",
"description": "Veikia tik išdėstymo ir temos priedai. Paleiskite serverį iš naujo be kintamojo TERMIX_SAFE_MODE, kad įjungtumėte likusius priedus."
},
"companion": {
"close": "Uždaryti skydelį",
"moveSide": "Perkelti skydelį",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Atstatyti numatytąjį visiems",
"update": "Atnaujinti {{name}} į {{version}}",
"disable": "Išjungti {{name}}",
"enable": "Įjungti „{{name}}“",
"startAgain": "Paleisti „{{name}}“ iš naujo",
"install": "Ieškoti išdėstymų ir temų",
"installTitle": "Išdėstymai ir temos",
"installButton": "Įdiegti",
@@ -1343,7 +1349,8 @@
"disabled": "Išjungta",
"failed": "Nenumatomai sustabdyta",
"blocked": "Laukiama kito priedo",
"server": "Veikia jūsų serveryje"
"server": "Veikia jūsų serveryje",
"safeMode": "Išjungta saugiu režimu"
},
"groups": {
"running": "Veikia",
@@ -1357,11 +1364,6 @@
"updated": "Neseniai atnaujinti",
"category": "Kategorija"
},
"columns": {
"title": "Stulpeliai",
"auto": "Automatiškai",
"count": "{{count}} pločio"
},
"table": {
"name": "Pavadinimas",
"description": "Aprašymas",
@@ -1736,7 +1738,12 @@
"gridView": "Tinklelis",
"listView": "Sąrašas",
"compactRows": "Kompaktiškos eilutės",
"comfortableRows": "Patogios eilutės"
"comfortableRows": "Patogios eilutės",
"columns": {
"title": "Stulpeliai",
"auto": "Automatiškai",
"count": "{{count}} pločio"
}
},
"fields": {
"secretSaved": "Išsaugota. Įveskite, kad pakeistumėte."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} ir gatavs instalēšanai",
"readyHint": "Tas tiks instalēts nākamajā reizi, kad Termix tiks restartēts."
},
"safeMode": {
"title": "Drošais režīms",
"description": "Darbojas tikai izkārtojuma un temas spraudņi. Restartējiet serveri bez TERMIX_SAFE_MODE, lai atkal ieslēgtu pārējos."
},
"companion": {
"close": "Aizvērt paneli",
"moveSide": "Pārvietot paneli",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Atiestatīt noklusējumu visiem",
"update": "Atjaunināt {{name}} uz {{version}}",
"disable": "Izslēgt {{name}}",
"enable": "Ieslēgt {{name}}",
"startAgain": "Atkārtoti palaist {{name}}",
"install": "Atrast izkārtojumus un tēmas",
"installTitle": "Izkārtojumi un tēmas",
"installButton": "Instalēt",
@@ -1326,7 +1332,8 @@
"disabled": "Atspējots",
"failed": "Gaidāms apstājās",
"blocked": "Gaida citu spraudni",
"server": "Darbojas jūsu serverī"
"server": "Darbojas jūsu serverī",
"safeMode": "Izslēgts drošajā režīmā"
},
"groups": {
"running": "Darbojas",
@@ -1340,11 +1347,6 @@
"updated": "Nesen atjaunināti",
"category": "Kategorija"
},
"columns": {
"title": "Kolonnas",
"auto": "Automātiski",
"count": "{{count}} kolonnu platums"
},
"table": {
"name": "Nosaukums",
"description": "Apraksts",
@@ -1694,7 +1696,12 @@
"gridView": "Režģis",
"listView": "Saraksts",
"compactRows": "Kompaktas rindas",
"comfortableRows": "Plašas rindas"
"comfortableRows": "Plašas rindas",
"columns": {
"title": "Kolonnas",
"auto": "Automātiski",
"count": "{{count}} kolonnu platums"
}
},
"fields": {
"secretSaved": "Saglabāts. Ierakstiet, lai to aizvietotu."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} sedia untuk dipasang",
"readyHint": "Ia akan dipasang pada kali seterusnya Termix dimulakan semula."
},
"safeMode": {
"title": "Modus selamat",
"description": "Hanya plugin susun atur dan tema yang sedang berjalan. Mulakan semula pelayan tanpa TERMIX_SAFE_MODE untuk menghidupkan kembali yang lain."
},
"companion": {
"close": "Tutup panel",
"moveSide": "Pindahkan panel",
@@ -1081,6 +1085,8 @@
"resetEveryone": "Set semula lalai untuk semua orang",
"update": "Kemas kini {{name}} ke {{version}}",
"disable": "Matikan {{name}}",
"enable": "Hidupkan {{name}}",
"startAgain": "Mulakan {{name}} semula",
"install": "Cari susun atur dan tema",
"installTitle": "Susun atur dan tema",
"installButton": "Pasang",
@@ -1292,7 +1298,8 @@
"disabled": "Dilumpuhkan",
"failed": "Berhenti secara tidak dijangka",
"blocked": "Menunggu plugin lain",
"server": "Berjalan pada pelayan anda"
"server": "Berjalan pada pelayan anda",
"safeMode": "Mati dalam modus selamat"
},
"groups": {
"running": "Berjalan",
@@ -1306,11 +1313,6 @@
"updated": "Dikemas kini baru-baru ini",
"category": "Kategori"
},
"columns": {
"title": "Lajur",
"auto": "Auto",
"count": "Lebar {{count}}"
},
"table": {
"name": "Nama",
"description": "Keterangan",
@@ -1610,7 +1612,12 @@
"gridView": "Grid",
"listView": "Senarai",
"compactRows": "Baris padat",
"comfortableRows": "Baris selesa"
"comfortableRows": "Baris selesa",
"columns": {
"title": "Lajur",
"auto": "Auto",
"count": "Lebar {{count}}"
}
},
"fields": {
"secretSaved": "Disimpan. Taip untuk menggantikannya."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} is klaar om te installeren",
"readyHint": "Het wordt geïnstalleerd de volgende keer dat Termix herstart."
},
"safeMode": {
"title": "Veilige modus",
"description": "Alleen layout- en thema-plugins zijn actief. Herstart de server zonder TERMIX_SAFE_MODE om de rest weer in te schakelen."
},
"companion": {
"close": "Sluit paneel",
"moveSide": "Verplaats paneel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Standaard voor iedereen terugzetten",
"update": "Update {{name}} naar {{version}}",
"disable": "Zet {{name}} uit",
"enable": "{{name}} inschakelen",
"startAgain": "{{name}} opnieuw starten",
"install": "Lay-outs en thema's zoeken",
"installTitle": "Lay-outs en thema's",
"installButton": "Installeren",
@@ -1309,7 +1315,8 @@
"disabled": "Uitgeschakeld",
"failed": "Onverwacht gestopt",
"blocked": "Wachten op een andere plugin",
"server": "Wordt uitgevoerd op je server"
"server": "Wordt uitgevoerd op je server",
"safeMode": "Uitgeschakeld in veilige modus"
},
"groups": {
"running": "Actief",
@@ -1323,11 +1330,6 @@
"updated": "Recent bijgewerkt",
"category": "Categorie"
},
"columns": {
"title": "Kolommen",
"auto": "Automatisch",
"count": "{{count}} kolommen breed"
},
"table": {
"name": "Naam",
"description": "Beschrijving",
@@ -1652,7 +1654,12 @@
"gridView": "Raster",
"listView": "Lijst",
"compactRows": "Compacte rijen",
"comfortableRows": "Ruime rijen"
"comfortableRows": "Ruime rijen",
"columns": {
"title": "Kolommen",
"auto": "Automatisch",
"count": "{{count}} kolommen breed"
}
},
"fields": {
"secretSaved": "Opgeslagen. Typ om het te vervangen."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} er klar til installasjon",
"readyHint": "Den installeres neste gang Termix starter på nytt."
},
"safeMode": {
"title": "Sikker modus",
"description": "Kun utforming- og tematillegg kjører. Start serveren på nytt uten TERMIX_SAFE_MODE for å slå på resten igjen."
},
"companion": {
"close": "Lukk panel",
"moveSide": "Flytt panel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Tilbakestill standard for alle",
"update": "Oppdater {{name}} til {{version}}",
"disable": "Slå av {{name}}",
"enable": "Slå på {{name}}",
"startAgain": "Start {{name}} på nytt",
"install": "Finn oppsett og temaer",
"installTitle": "Oppsett og temaer",
"installButton": "Installer",
@@ -1309,7 +1315,8 @@
"disabled": "Deaktivert",
"failed": "Stoppet uventet",
"blocked": "Venter på et annet plugin",
"server": "Kjører på serveren din"
"server": "Kjører på serveren din",
"safeMode": "Av i sikker modus"
},
"groups": {
"running": "Kjører",
@@ -1323,11 +1330,6 @@
"updated": "Nylig oppdatert",
"category": "Kategori"
},
"columns": {
"title": "Kolonner",
"auto": "Auto",
"count": "{{count}} bred"
},
"table": {
"name": "Navn",
"description": "Beskrivelse",
@@ -1652,7 +1654,12 @@
"gridView": "Rutenett",
"listView": "Liste",
"compactRows": "Kompakte rader",
"comfortableRows": "Komfortable rader"
"comfortableRows": "Komfortable rader",
"columns": {
"title": "Kolonner",
"auto": "Auto",
"count": "{{count}} bred"
}
},
"fields": {
"secretSaved": "Lagret. Skriv for å erstatte den."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} jest gotowy do instalacji",
"readyHint": "Zostanie zainstalowany przy następnym ponownym uruchomieniu Termix."
},
"safeMode": {
"title": "Tryb awaryjny",
"description": "Uruchomione są tylko wtyczki układu i motywu. Uruchom ponownie serwer bez zmiennej środowiskowej TERMIX_SAFE_MODE, aby włączyć pozostałe wtyczki."
},
"companion": {
"close": "Zamknij panel",
"moveSide": "Przenieś panel",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Przywróć domyślne ustawienia dla wszystkich",
"update": "Aktualizuj {{name}} do wersji {{version}}",
"disable": "Wyłącz {{name}}",
"enable": "Włącz {{name}}",
"startAgain": "Uruchom ponownie {{name}}",
"install": "Szukaj układów i motywów",
"installTitle": "Układy i motywy",
"installButton": "Zainstaluj",
@@ -1343,7 +1349,8 @@
"disabled": "Wyłączone",
"failed": "Zatrzymane nieoczekiwanie",
"blocked": "Oczekiwanie na inną wtyczkę",
"server": "Uruchamiane na Twoim serwerze"
"server": "Uruchamiane na Twoim serwerze",
"safeMode": "Wyłączone w trybie awaryjnym"
},
"groups": {
"running": "Uruchomione",
@@ -1357,11 +1364,6 @@
"updated": "Ostatnio zaktualizowane",
"category": "Kategoria"
},
"columns": {
"title": "Kolumny",
"auto": "Automatycznie",
"count": "{{count}} kolumn"
},
"table": {
"name": "Nazwa",
"description": "Opis",
@@ -1736,7 +1738,12 @@
"gridView": "Siatka",
"listView": "Lista",
"compactRows": "Zwięzłe wiersze",
"comfortableRows": "Przestrzenne wiersze"
"comfortableRows": "Przestrzenne wiersze",
"columns": {
"title": "Kolumny",
"auto": "Automatycznie",
"count": "Szerokość: {{count}}"
}
},
"fields": {
"secretSaved": "Zapisano. Wpisz nową wartość, aby ją zmienić."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} está pronto para instalação",
"readyHint": "Será instalado na próxima vez que o Termix reiniciar."
},
"safeMode": {
"title": "Modo de segurança",
"description": "Apenas os plugins de layout e tema estão ativos. Reinicie o servidor sem a variável TERMIX_SAFE_MODE para reativar os demais."
},
"companion": {
"close": "Fechar painel",
"moveSide": "Mover painel",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Restaurar o padrão para todos",
"update": "Atualizar {{name}} para {{version}}",
"disable": "Desativar {{name}}",
"enable": "Ativar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Encontrar layouts e temas",
"installTitle": "Layouts e temas",
"installButton": "Instalar",
@@ -1326,7 +1332,8 @@
"disabled": "Desativado",
"failed": "Parado inesperadamente",
"blocked": "Aguardando outro plugin",
"server": "Executa no seu servidor"
"server": "Executa no seu servidor",
"safeMode": "Desativado no modo de segurança"
},
"groups": {
"running": "Em execução",
@@ -1340,11 +1347,6 @@
"updated": "Atualizados recentemente",
"category": "Categoria"
},
"columns": {
"title": "Colunas",
"auto": "Automático",
"count": "{{count}} de largura"
},
"table": {
"name": "Nome",
"description": "Descrição",
@@ -1694,7 +1696,12 @@
"gridView": "Grade",
"listView": "Lista",
"compactRows": "Linhas compactas",
"comfortableRows": "Linhas confortáveis"
"comfortableRows": "Linhas confortáveis",
"columns": {
"title": "Colunas",
"auto": "Automático",
"count": "{{count}} colunas"
}
},
"fields": {
"secretSaved": "Salvo. Digite para substituí-lo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "O Termix {{version}} está pronto para instalar",
"readyHint": "Será instalado na próxima reinicialização do Termix."
},
"safeMode": {
"title": "Modo seguro",
"description": "Apenas os plugins de layout e tema estão em execução. Reinicie o servidor sem a variável TERMIX_SAFE_MODE para reativar os restantes."
},
"companion": {
"close": "Fechar painel",
"moveSide": "Mover painel",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Repor a predefinição para todos",
"update": "Atualizar {{name}} para {{version}}",
"disable": "Desativar {{name}}",
"enable": "Ativar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Procurar layouts e temas",
"installTitle": "Layouts e temas",
"installButton": "Instalar",
@@ -1326,7 +1332,8 @@
"disabled": "Desativado",
"failed": "Parado inesperadamente",
"blocked": "À espera de outro plugin",
"server": "Executa no seu servidor"
"server": "Executa no seu servidor",
"safeMode": "Desativado no modo seguro"
},
"groups": {
"running": "Em execução",
@@ -1340,11 +1347,6 @@
"updated": "Atualizados recentemente",
"category": "Categoria"
},
"columns": {
"title": "Colunas",
"auto": "Automático",
"count": "{{count}} de largura"
},
"table": {
"name": "Nome",
"description": "Descrição",
@@ -1694,7 +1696,12 @@
"gridView": "Grelha",
"listView": "Lista",
"compactRows": "Linhas compactas",
"comfortableRows": "Linhas confortáveis"
"comfortableRows": "Linhas confortáveis",
"columns": {
"title": "Colunas",
"auto": "Automático",
"count": "{{count}} de largura"
}
},
"fields": {
"secretSaved": "Guardado. Escreva para substituir."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} este gata de instalare",
"readyHint": "Se instalează data viitoare când Termix se repornește."
},
"safeMode": {
"title": "Mod sigur",
"description": "Doar plugin-urile de aspect și temă sunt active. Repornește serverul fără variabila TERMIX_SAFE_MODE pentru a reactiva restul."
},
"companion": {
"close": "Închide panoul",
"moveSide": "Mută panoul",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Resetează implicitul pentru toți",
"update": "Actualizează {{name}} la {{version}}",
"disable": "Dezactivează {{name}}",
"enable": "Activează {{name}}",
"startAgain": "Pornește din nou {{name}}",
"install": "Găsește aspecte și teme",
"installTitle": "Aspecte și teme",
"installButton": "Instalează",
@@ -1326,7 +1332,8 @@
"disabled": "Dezactivat",
"failed": "Oprit neașteptat",
"blocked": "Așteaptă un alt plugin",
"server": "Rulează pe serverul tău"
"server": "Rulează pe serverul tău",
"safeMode": "Oprit în modul sigur"
},
"groups": {
"running": "În execuție",
@@ -1340,11 +1347,6 @@
"updated": "Actualizate recent",
"category": "Categorie"
},
"columns": {
"title": "Coloane",
"auto": "Automat",
"count": "{{count}} coloane"
},
"table": {
"name": "Nume",
"description": "Descriere",
@@ -1694,7 +1696,12 @@
"gridView": "Grilă",
"listView": "Listă",
"compactRows": "Rânduri compacte",
"comfortableRows": "Rânduri confortabile"
"comfortableRows": "Rânduri confortabile",
"columns": {
"title": "Coloane",
"auto": "Automat",
"count": "{{count}} coloane"
}
},
"fields": {
"secretSaved": "Salvat. Tastează pentru a-l înlocui."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} готова к установке",
"readyHint": "Она будет установлена при следующем перезапуске Termix."
},
"safeMode": {
"title": "Безопасный режим",
"description": "Работают только плагины макета и темы. Перезапустите сервер без флага TERMIX_SAFE_MODE, чтобы включить остальные плагины."
},
"companion": {
"close": "Закрыть панель",
"moveSide": "Переместить панель",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Сбросить настройки по умолчанию для всех",
"update": "Обновить {{name}} до версии {{version}}",
"disable": "Отключить {{name}}",
"enable": "Включить {{name}}",
"startAgain": "Запустить {{name}} заново",
"install": "Найти макеты и темы",
"installTitle": "Макеты и темы",
"installButton": "Установить",
@@ -1343,7 +1349,8 @@
"disabled": "Отключен",
"failed": "Неожиданно остановлен",
"blocked": "Ожидание другого плагина",
"server": "Работает на вашем сервере"
"server": "Работает на вашем сервере",
"safeMode": "Отключено в безопасном режиме"
},
"groups": {
"running": "Запущенные",
@@ -1357,11 +1364,6 @@
"updated": "Недавно обновленные",
"category": "Категория"
},
"columns": {
"title": "Столбцы",
"auto": "Авто",
"count": "{{count}} столбцов"
},
"table": {
"name": "Имя",
"description": "Описание",
@@ -1736,7 +1738,12 @@
"gridView": "Сетка",
"listView": "Список",
"compactRows": "Компактные строки",
"comfortableRows": "Удобные строки"
"comfortableRows": "Удобные строки",
"columns": {
"title": "Колонки",
"auto": "Авто",
"count": "{{count}} колонок"
}
},
"fields": {
"secretSaved": "Сохранено. Введите новый текст, чтобы заменить."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} je pripravený na inštaláciu",
"readyHint": "Nainštaluje sa pri ďalšom reštarte Termixu."
},
"safeMode": {
"title": "Režim bezpečnosti",
"description": "Bežia len zásuvné moduly rozloženia a témy. Reštartujte server bez premennej TERMIX_SAFE_MODE, aby ste ostatné moduly opäť povolili."
},
"companion": {
"close": "Zatvoriť panel",
"moveSide": "Presunúť panel",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Obnoviť predvolené nastavenie pre všetkých",
"update": "Aktualizovať {{name}} na verziu {{version}}",
"disable": "Vypnúť {{name}}",
"enable": "Povoliť {{name}}",
"startAgain": "Spustiť {{name}} znova",
"install": "Nájsť rozloženia a témy",
"installTitle": "Rozloženia a témy",
"installButton": "Nainštalovať",
@@ -1343,7 +1349,8 @@
"disabled": "Zakázané",
"failed": "Neočakávane zastavené",
"blocked": "Čaká na iný plugin",
"server": "Beží na vašom serveri"
"server": "Beží na vašom serveri",
"safeMode": "Vypnuté v režime bezpečnosti"
},
"groups": {
"running": "Bežiaci",
@@ -1357,11 +1364,6 @@
"updated": "Nedávno aktualizované",
"category": "Kategória"
},
"columns": {
"title": "Stĺpce",
"auto": "Automaticky",
"count": "{{count}} stĺpcov široké"
},
"table": {
"name": "Názov",
"description": "Popis",
@@ -1736,7 +1738,12 @@
"gridView": "Mriežka",
"listView": "Zoznam",
"compactRows": "Kompaktné riadky",
"comfortableRows": "Pohodlné riadky"
"comfortableRows": "Pohodlné riadky",
"columns": {
"title": "Stĺpce",
"auto": "Automaticky",
"count": "Šírka {{count}}"
}
},
"fields": {
"secretSaved": "Uložené. Pre nahradenie zadajte novú hodnotu."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} je pripravljen za namestitev",
"readyHint": "Namesti se naslednjič, ko se Termix ponovno zažene."
},
"safeMode": {
"title": "Varen način",
"description": "Zagnani so le vtičniki za postavitev in temo. Ponovno zaženite strežnik brez TERMIX_SAFE_MODE, da ponovno vklopite ostale."
},
"companion": {
"close": "Zapri ploščo",
"moveSide": "Premakni ploščo",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Ponastavi privzeto za vse",
"update": "Posodobi {{name}} na {{version}}",
"disable": "Izklopi {{name}}",
"enable": "Vklopi {{name}}",
"startAgain": "Znova zaženi {{name}}",
"install": "Poišči razporede in teme",
"installTitle": "Razporedi in teme",
"installButton": "Namesti",
@@ -1343,7 +1349,8 @@
"disabled": "Onemogočen",
"failed": "Nepričakovano ustavljen",
"blocked": "Čaka na drug vtičnik",
"server": "Zažene se na vašem strežniku"
"server": "Zažene se na vašem strežniku",
"safeMode": "Izklopljeno v varnem načinu"
},
"groups": {
"running": "Delujoči",
@@ -1357,11 +1364,6 @@
"updated": "Nedavno posodobljeni",
"category": "Kategorija"
},
"columns": {
"title": "Stolpci",
"auto": "Samodejno",
"count": "{{count}} širok"
},
"table": {
"name": "Ime",
"description": "Opis",
@@ -1736,7 +1738,12 @@
"gridView": "Mreža",
"listView": "Seznam",
"compactRows": "Kompaktni vrstice",
"comfortableRows": "Udobne vrstice"
"comfortableRows": "Udobne vrstice",
"columns": {
"title": "Stolpci",
"auto": "Samodejno",
"count": "{{count}} širok"
}
},
"fields": {
"secretSaved": "Shranjeno. Vnesite za zamenjavo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} је спреман за инсталацију",
"readyHint": "Инсталира се следећи пут када се Termix поново покрене."
},
"safeMode": {
"title": "Безбедан режим",
"description": "Раде само додаци за распоред и тему. Поново покрените сервер без TERMIX_SAFE_MODE да бисте укључили остале."
},
"companion": {
"close": "Затвори панел",
"moveSide": "Помери панел",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Ресетуј подразумевано за све",
"update": "Ажурирај {{name}} на {{version}}",
"disable": "Искључи {{name}}",
"enable": "Укључи {{name}}",
"startAgain": "Поново покрените {{name}}",
"install": "Пронађи распоред и теме",
"installTitle": "Распореди и теме",
"installButton": "Инсталирај",
@@ -1326,7 +1332,8 @@
"disabled": "Онемогућено",
"failed": "Неочекивано заустављено",
"blocked": "Чека на други додатак",
"server": "Ради на вашем серверу"
"server": "Ради на вашем серверу",
"safeMode": "Искључено у безбедном режиму"
},
"groups": {
"running": "Покренуто",
@@ -1340,11 +1347,6 @@
"updated": "Недавно ажурирано",
"category": "Категорија"
},
"columns": {
"title": "Колоне",
"auto": "Аутоматски",
"count": "{{count}} широка"
},
"table": {
"name": "Име",
"description": "Опис",
@@ -1694,7 +1696,12 @@
"gridView": "Мрежа",
"listView": "Листа",
"compactRows": "Компактни редови",
"comfortableRows": "Удобни редови"
"comfortableRows": "Удобни редови",
"columns": {
"title": "Колоне",
"auto": "Аутоматски",
"count": "{{count}} широка"
}
},
"fields": {
"secretSaved": "Сачувано. Унесите да бисте га заменили."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} är redo att installeras",
"readyHint": "Den installeras nästa gång Termix startas om."
},
"safeMode": {
"title": "Säkerhetsläge",
"description": "Endast layout- och temapluggar är aktiva. Starta om servern utan TERMIX_SAFE_MODE för att aktivera de övriga igen."
},
"companion": {
"close": "Stäng panel",
"moveSide": "Flytta panel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Återställ standarden för alla",
"update": "Uppdatera {{name}} till {{version}}",
"disable": "Stäng av {{name}}",
"enable": "Aktivera {{name}}",
"startAgain": "Starta {{name}} igen",
"install": "Hitta layouter och teman",
"installTitle": "Layouter och teman",
"installButton": "Installera",
@@ -1309,7 +1315,8 @@
"disabled": "Inaktiverad",
"failed": "Stoppades oväntat",
"blocked": "Väntar på ett annat plugin",
"server": "Kör på din server"
"server": "Kör på din server",
"safeMode": "Avstängd i säkert läge"
},
"groups": {
"running": "Körs",
@@ -1323,11 +1330,6 @@
"updated": "Nyligen uppdaterade",
"category": "Kategori"
},
"columns": {
"title": "Kolumner",
"auto": "Auto",
"count": "{{count}} breda"
},
"table": {
"name": "Namn",
"description": "Beskrivning",
@@ -1652,7 +1654,12 @@
"gridView": "Rutnät",
"listView": "Lista",
"compactRows": "Kompakta rader",
"comfortableRows": "Bekväma rader"
"comfortableRows": "Bekväma rader",
"columns": {
"title": "Kolumner",
"auto": "Automatiskt",
"count": "{{count}} bred"
}
},
"fields": {
"secretSaved": "Sparat. Skriv för att ersätta det."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix เวอร์ชัน {{version}} พร้อมติดตั้ง",
"readyHint": "จะติดตั้งเมื่อ Termix รีสตาร์ทครั้งถัดไป"
},
"safeMode": {
"title": "โหมดปลอดภัย",
"description": "กำลังทำงานเฉพาะปลั๊กอินเลย์เอาต์และธีมเท่านั้น โปรดรีสตาร์ทเซิร์ฟเวอร์โดยยกเลิกการตั้งค่า TERMIX_SAFE_MODE เพื่อเปิดใช้งานส่วนที่เหลือ"
},
"companion": {
"close": "ปิดแผง",
"moveSide": "ย้ายแผง",
@@ -1081,6 +1085,8 @@
"resetEveryone": "รีเซ็ตค่าเริ่มต้นสำหรับทุกคน",
"update": "อัปเดต {{name}} เป็น {{version}}",
"disable": "ปิดใช้งาน {{name}}",
"enable": "เปิดใช้งาน {{name}}",
"startAgain": "เริ่ม {{name}} อีกครั้ง",
"install": "ค้นหาการจัดวางและธีม",
"installTitle": "การจัดวางและธีม",
"installButton": "ติดตั้ง",
@@ -1292,7 +1298,8 @@
"disabled": "ปิดใช้งาน",
"failed": "หยุดทำงานโดยไม่คาดคิด",
"blocked": "กำลังรอปลั๊กอินอื่น",
"server": "ทำงานบนเซิร์ฟเวอร์ของคุณ"
"server": "ทำงานบนเซิร์ฟเวอร์ของคุณ",
"safeMode": "ปิดใช้งานในโหมดปลอดภัย"
},
"groups": {
"running": "กำลังทำงาน",
@@ -1306,11 +1313,6 @@
"updated": "อัปเดตล่าสุด",
"category": "หมวดหมู่"
},
"columns": {
"title": "คอลัมน์",
"auto": "อัตโนมัติ",
"count": "กว้าง {{count}} คอลัมน์"
},
"table": {
"name": "ชื่อ",
"description": "คำอธิบาย",
@@ -1610,7 +1612,12 @@
"gridView": "ตาราง",
"listView": "รายการ",
"compactRows": "แถวแบบกะทัดรัด",
"comfortableRows": "แถวแบบสบายตา"
"comfortableRows": "แถวแบบสบายตา",
"columns": {
"title": "คอลัมน์",
"auto": "อัตโนมัติ",
"count": "กว้าง {{count}} คอลัมน์"
}
},
"fields": {
"secretSaved": "บันทึกแล้ว พิมพ์เพื่อแทนที่"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} yüklenmeye hazır",
"readyHint": "Termix yeniden başladığında yüklenir."
},
"safeMode": {
"title": "Güvenli mod",
"description": "Sadece düzen ve tema eklentileri çalışıyor. Geri kalanını tekrar açmak için sunucuyu TERMIX_SAFE_MODE olmadan yeniden başlatın."
},
"companion": {
"close": "Paneli kapat",
"moveSide": "Paneli taşı",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Herkes için varsayılanı sıfırla",
"update": "{{name}}'i {{version}} sürümüne güncelle",
"disable": "{{name}}'i kapat",
"enable": "{{name}}'i aç",
"startAgain": "{{name}}'i yeniden başlat",
"install": "Düzen ve temaları bul",
"installTitle": "Düzenler ve temalar",
"installButton": "Yükle",
@@ -1309,7 +1315,8 @@
"disabled": "Devre dışı",
"failed": "Beklenmedik şekilde durdu",
"blocked": "Başka bir eklentiyi bekliyor",
"server": "Sunucunuzda çalışır"
"server": "Sunucunuzda çalışır",
"safeMode": "Güvenli modda kapalı"
},
"groups": {
"running": "Çalışıyor",
@@ -1323,11 +1330,6 @@
"updated": "Son güncellenen",
"category": "Kategori"
},
"columns": {
"title": "Sütunlar",
"auto": "Otomatik",
"count": "{{count}} geniş"
},
"table": {
"name": "Ad",
"description": "Açıklama",
@@ -1652,7 +1654,12 @@
"gridView": "Izgara",
"listView": "Liste",
"compactRows": "Sıkı satırlar",
"comfortableRows": "Rahat satırlar"
"comfortableRows": "Rahat satırlar",
"columns": {
"title": "Sütunlar",
"auto": "Otomatik",
"count": "{{count}} geniş"
}
},
"fields": {
"secretSaved": "Kaydedildi. Değiştirmek için yazın."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} готова до встановлення",
"readyHint": "Вона встановиться під час наступного перезапуску Termix."
},
"safeMode": {
"title": "Безпечний режим",
"description": "Працюють лише плагіни макету та теми. Перезапустіть сервер без змінної TERMIX_SAFE_MODE, щоб увімкнути решту плагінів."
},
"companion": {
"close": "Закрити панель",
"moveSide": "Перемістити панель",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Скинути значення за замовчуванням для всіх",
"update": "Оновити {{name}} до {{version}}",
"disable": "Вимкнути {{name}}",
"enable": "Увімкнути {{name}}",
"startAgain": "Запустити {{name}} знову",
"install": "Знайти макети та теми",
"installTitle": "Макети та теми",
"installButton": "Встановити",
@@ -1343,7 +1349,8 @@
"disabled": "Вимкнено",
"failed": "Зупинено несподівано",
"blocked": "Очікує на інший плагін",
"server": "Працює на вашому сервері"
"server": "Працює на вашому сервері",
"safeMode": "Вимкнено в безпечному режимі"
},
"groups": {
"running": "Запущено",
@@ -1357,11 +1364,6 @@
"updated": "Нещодавно оновлено",
"category": "Категорія"
},
"columns": {
"title": "Стовпці",
"auto": "Авто",
"count": "{{count}} стовпців"
},
"table": {
"name": "Назва",
"description": "Опис",
@@ -1736,7 +1738,12 @@
"gridView": "Сітка",
"listView": "Список",
"compactRows": "Компактні рядки",
"comfortableRows": "Зручні рядки"
"comfortableRows": "Зручні рядки",
"columns": {
"title": "Стовпці",
"auto": "Авто",
"count": "{{count}} стовпців"
}
},
"fields": {
"secretSaved": "Збережено. Введіть текст, щоб замінити його."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} đã sẵn sàng để cài đặt",
"readyHint": "Nó sẽ được cài đặt khi Termix khởi động lại lần tới."
},
"safeMode": {
"title": "Chế độ an toàn",
"description": "Chỉ có các plugin bố cục và giao diện đang chạy. Khởi động lại máy chủ mà không có TERMIX_SAFE_MODE để bật lại các plugin còn lại."
},
"companion": {
"close": "Đóng bảng",
"moveSide": "Di chuyển bảng",
@@ -1081,6 +1085,8 @@
"resetEveryone": "Đặt lại mặc định cho mọi người",
"update": "Cập nhật {{name}} lên {{version}}",
"disable": "Tắt {{name}}",
"enable": "Bật {{name}}",
"startAgain": "Khởi động lại {{name}}",
"install": "Tìm bố cục và giao diện",
"installTitle": "Bố cục và giao diện",
"installButton": "Cài đặt",
@@ -1292,7 +1298,8 @@
"disabled": "Đã tắt",
"failed": "Dừng bất ngờ",
"blocked": "Đang chờ plugin khác",
"server": "Chạy trên máy chủ của bạn"
"server": "Chạy trên máy chủ của bạn",
"safeMode": "Tắt trong chế độ an toàn"
},
"groups": {
"running": "Đang chạy",
@@ -1306,11 +1313,6 @@
"updated": "Mới cập nhật",
"category": "Danh mục"
},
"columns": {
"title": "Cột",
"auto": "Tự động",
"count": "Rộng {{count}}"
},
"table": {
"name": "Tên",
"description": "Mô tả",
@@ -1610,7 +1612,12 @@
"gridView": "Lưới",
"listView": "Danh sách",
"compactRows": "Hàng gọn",
"comfortableRows": "Hàng rộng"
"comfortableRows": "Hàng rộng",
"columns": {
"title": "Cột",
"auto": "Tự động",
"count": "Rộng {{count}}"
}
},
"fields": {
"secretSaved": "Đã lưu. Nhập để thay thế."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} 已准备好安装",
"readyHint": "下次重启 Termix 时将进行安装。"
},
"safeMode": {
"title": "安全模式",
"description": "仅运行布局和主题插件。重启服务器并移除 TERMIX_SAFE_MODE 环境变量以恢复其他插件。"
},
"companion": {
"close": "关闭面板",
"moveSide": "移动面板",
@@ -1081,6 +1085,8 @@
"resetEveryone": "重置所有人的默认设置",
"update": "将 {{name}} 更新到 {{version}}",
"disable": "关闭 {{name}}",
"enable": "启用 {{name}}",
"startAgain": "重新启动 {{name}}",
"install": "查找布局和主题",
"installTitle": "布局和主题",
"installButton": "安装",
@@ -1292,7 +1298,8 @@
"disabled": "已禁用",
"failed": "意外停止",
"blocked": "等待其他插件",
"server": "在您的服务器上运行"
"server": "在您的服务器上运行",
"safeMode": "安全模式下已关闭"
},
"groups": {
"running": "正在运行",
@@ -1306,11 +1313,6 @@
"updated": "最近更新",
"category": "类别"
},
"columns": {
"title": "列数",
"auto": "自动",
"count": "{{count}} 列宽"
},
"table": {
"name": "名称",
"description": "描述",
@@ -1610,7 +1612,12 @@
"gridView": "网格",
"listView": "列表",
"compactRows": "紧凑行",
"comfortableRows": "宽松行"
"comfortableRows": "宽松行",
"columns": {
"title": "列数",
"auto": "自动",
"count": "{{count}} 列宽"
}
},
"fields": {
"secretSaved": "已保存。输入以替换。"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} 已準備好安裝",
"readyHint": "下次重新啟動 Termix 時將進行安裝。"
},
"safeMode": {
"title": "安全模式",
"description": "目前僅執行版面配置與主題外掛。請在移除 TERMIX_SAFE_MODE 環境變數後重新啟動伺服器,以恢復其他外掛。"
},
"companion": {
"close": "關閉面板",
"moveSide": "移動面板",
@@ -1081,6 +1085,8 @@
"resetEveryone": "重設所有人的預設值",
"update": "將 {{name}} 更新至 {{version}}",
"disable": "關閉 {{name}}",
"enable": "啟用 {{name}}",
"startAgain": "重新啟動 {{name}}",
"install": "尋找版面配置與主題",
"installTitle": "版面配置與主題",
"installButton": "安裝",
@@ -1292,7 +1298,8 @@
"disabled": "已停用",
"failed": "意外停止",
"blocked": "等待其他外掛",
"server": "在您的伺服器上執行"
"server": "在您的伺服器上執行",
"safeMode": "安全模式下已停用"
},
"groups": {
"running": "執行中",
@@ -1306,11 +1313,6 @@
"updated": "最近更新",
"category": "類別"
},
"columns": {
"title": "欄數",
"auto": "自動",
"count": "寬度 {{count}}"
},
"table": {
"name": "名稱",
"description": "說明",
@@ -1610,7 +1612,12 @@
"gridView": "網格",
"listView": "清單",
"compactRows": "緊湊列",
"comfortableRows": "寬鬆列"
"comfortableRows": "寬鬆列",
"columns": {
"title": "欄位",
"auto": "自動",
"count": "寬度 {{count}}"
}
},
"fields": {
"secretSaved": "已儲存。輸入以取代。"
+8 -3
View File
@@ -221,6 +221,8 @@
"desktopUpdates.manualHint": "48b49fd6307c",
"desktopUpdates.ready": "faadfb86e8e8",
"desktopUpdates.readyHint": "dd55ccd0ee8d",
"safeMode.title": "c730d398556a",
"safeMode.description": "f6746478af76",
"companion.close": "fa0b7b3ac0f0",
"companion.moveSide": "4e7610d6ac13",
"companion.resize": "c3689b810f1d",
@@ -1005,6 +1007,8 @@
"looks.repair.resetEveryone": "219daf273b31",
"looks.repair.update": "9930e068ecbe",
"looks.repair.disable": "f5be6c90fa8d",
"looks.repair.enable": "507cd8f53166",
"looks.repair.startAgain": "24dbed327ec1",
"looks.repair.install": "904997c1d819",
"looks.repair.installTitle": "378817c80794",
"looks.repair.installButton": "fd6c3ebf7bef",
@@ -1124,6 +1128,7 @@
"plugins.manager.status.failed": "71f9a28d7960",
"plugins.manager.status.blocked": "911f8f3cbad4",
"plugins.manager.status.server": "212cbe47e65e",
"plugins.manager.status.safeMode": "e850fafec9cd",
"plugins.manager.groups.running": "73989d9c5926",
"plugins.manager.groups.notRunning": "2a2cd4b515b7",
"plugins.manager.groups.onServer": "63ca7153c2d1",
@@ -1132,9 +1137,6 @@
"plugins.manager.sort.name": "709a23220f2c",
"plugins.manager.sort.updated": "5f0500ccf919",
"plugins.manager.sort.category": "a3c686e711e4",
"plugins.manager.columns.title": "cf723c59b62a",
"plugins.manager.columns.auto": "c614ba7c453c",
"plugins.manager.columns.count": "e8f2c5983611",
"plugins.manager.table.name": "709a23220f2c",
"plugins.manager.table.description": "55f8ebc805e6",
"plugins.manager.table.version": "2da600bf9404",
@@ -1392,6 +1394,9 @@
"panel.listView": "a1fffaaafb7c",
"panel.compactRows": "b17f552f3b98",
"panel.comfortableRows": "4c5b39191ead",
"panel.columns.title": "cf723c59b62a",
"panel.columns.auto": "c614ba7c453c",
"panel.columns.count": "e8f2c5983611",
"fields.secretSaved": "81d793988e7a",
"manage.needsAttention": "75674c819d3e",
"manage.hostCount": "4279c8d92ede",
+1
View File
@@ -130,6 +130,7 @@ function serverAllowsFrontend(summary: PluginSummary): boolean {
summary.enabled &&
summary.state !== "failed" &&
summary.state !== "blocked" &&
summary.state !== "safe-mode" &&
!!summary.frontend
);
}
+1
View File
@@ -289,6 +289,7 @@ export {
PanelShell,
Segmented,
ViewToggle,
type GridSize,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
+3 -1
View File
@@ -74,7 +74,9 @@ export function unregisteredViewStatus(
return { status: getPluginStoreState().loaded ? "missing" : "loading" };
}
const serverState = owner.summary.state;
if (!owner.summary.enabled) return { status: "disabled", owner };
if (!owner.summary.enabled || serverState === "safe-mode") {
return { status: "disabled", owner };
}
if (
owner.frontend === "failed" ||
owner.frontend === "blocked" ||
+31 -61
View File
@@ -5,7 +5,6 @@ import {
Check,
ChevronLeft,
ChevronRight,
Columns3,
Download,
Globe,
Library,
@@ -37,7 +36,9 @@ import {
GroupHeading,
PANEL,
PanelShell,
GRID_SIZES,
ViewToggle,
type GridSize,
type PanelViewMode,
} from "@/components/panel-layout";
import { DataView, type DataColumn } from "@/components/data-view";
@@ -72,9 +73,6 @@ import { DocsLink } from "@/components/docs-link";
type Section = "installed" | "browse" | "updates" | "registries";
const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
type GridSize = (typeof GRID_SIZES)[number];
interface Layout {
view: PanelViewMode;
columns: GridSize;
@@ -364,54 +362,16 @@ function LayoutControls({
onView: (next: PanelViewMode) => void;
onColumns: (next: GridSize) => void;
}) {
const { t } = useTranslation();
return (
<>
{layout.view === "grid" && (
<Select
value={layout.columns}
onValueChange={(v) => onColumns(v as GridSize)}
>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("plugins.manager.columns.title")}
aria-label={t("plugins.manager.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("plugins.manager.columns.auto")
: t("plugins.manager.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<ViewToggle view={layout.view} onView={onView} />
</>
<ViewToggle
view={layout.view}
onView={onView}
columns={layout.columns}
onColumns={onColumns}
/>
);
}
// Sized off the tab's own width, not the window. Cards keep a min width,
// so narrow panes drop columns on their own.
function gridStyle(columns: GridSize): React.CSSProperties {
if (columns === "auto") {
return {
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 400px), 1fr))",
};
}
const n = Number(columns);
const gaps = (n - 1) * 12;
return {
gridTemplateColumns: `repeat(auto-fill, minmax(max(220px, calc((100% - ${gaps}px) / ${n} - 0.5px)), 1fr))`,
};
}
function PluginCollection({
plugins,
layout,
@@ -440,13 +400,17 @@ function PluginCollection({
);
}
return (
<div className="grid gap-3" style={gridStyle(layout.columns)}>
{plugins.map((p) => (
<div key={p.id} className="flex flex-col *:flex-1">
{renderCard(p)}
</div>
))}
</div>
<DataView
items={plugins}
view="grid"
getKey={(p) => p.id}
renderCard={renderCard}
columns={{}}
size={layout.columns}
minCardWidth={400}
listColumns={listColumns}
empty={null}
/>
);
}
@@ -1133,7 +1097,9 @@ function InstalledCard({
<span className="text-[11px] text-muted-foreground">
{plugin.status === "server"
? t("plugins.manager.status.server")
: t("plugins.manager.status.disabled")}
: plugin.status === "safeMode"
? t("plugins.manager.status.safeMode")
: t("plugins.manager.status.disabled")}
</span>
</div>
) : (
@@ -1226,11 +1192,15 @@ function StatusCell({ plugin }: { plugin: PluginEntry }) {
? t("plugins.manager.status.failed")
: plugin.status === "server"
? t("plugins.manager.status.server")
: !running
? t("plugins.manager.status.disabled")
: plugin.pinnedVersion
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
: t("plugins.manager.status.running");
: plugin.status === "safeMode"
? t("plugins.manager.status.safeMode")
: !running
? t("plugins.manager.status.disabled")
: plugin.pinnedVersion
? t("plugins.manager.pinnedTo", {
version: plugin.pinnedVersion,
})
: t("plugins.manager.status.running");
return (
<span
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
+2 -1
View File
@@ -25,7 +25,7 @@ import {
} from "@/lib/issue-url";
export type PluginStatus =
"running" | "disabled" | "failed" | "blocked" | "server";
"running" | "disabled" | "failed" | "blocked" | "server" | "safeMode";
/** One row of the Plugins tab: what is installed merged with the registry. */
export interface PluginEntry {
@@ -121,6 +121,7 @@ export function pluginStatus(summary: PluginSummary): PluginStatus {
if (summary.state === "failed") return "failed";
if (summary.state === "blocked") return "blocked";
if (!summary.enabled) return "disabled";
if (summary.state === "safe-mode") return "safeMode";
return "running";
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useLookState } from "@/look/look-store";
const TOAST_ID = "safe-mode";
/** Tells everyone the server runs in safe mode, until they close it. */
export function SafeModeNotice() {
const { t } = useTranslation();
const safeMode = useLookState().response?.safeMode === true;
useEffect(() => {
if (!safeMode) return;
toast.warning(t("safeMode.title"), {
id: TOAST_ID,
description: t("safeMode.description"),
duration: Infinity,
closeButton: true,
});
}, [safeMode, t]);
return null;
}
+2
View File
@@ -3,6 +3,7 @@ import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { ComponentSlot } from "@/shell/ActionSlot";
import { OnboardingHost } from "@/onboarding/OnboardingHost";
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
import { SafeModeNotice } from "@/shell/SafeModeNotice";
import { TabPortals } from "@/shell/stage/TabPortals";
import { LayoutHost } from "@/shell/layout/LayoutHost";
import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
@@ -25,6 +26,7 @@ export function ShellRoot({
<TabPortals />
<ComponentSlot slotId="shell.overlay" />
<OnboardingHost />
<SafeModeNotice />
{isElectron() && <DesktopUpdateToasts />}
</ShellControllerProvider>
</ServerStatusProvider>
@@ -34,6 +34,24 @@ describe("DataView", () => {
expect(screen.queryByText("Name")).toBeNull();
});
it("uses the picked size over breakpoint columns", () => {
const { container } = render(
<DataView
items={items}
view="grid"
getKey={(item) => item.id}
renderCard={(item) => <div>card {item.name}</div>}
columns={{ base: 1, md: 2 }}
size="3"
listColumns={[]}
empty={null}
/>,
);
const grid = container.firstElementChild as HTMLElement;
expect(grid.className).not.toContain("md:grid-cols-2");
expect(grid.style.gridTemplateColumns).toContain("/ 3");
});
it("renders a header and clickable rows in list mode", () => {
const onRowClick = vi.fn();
renderView("list", onRowClick);
@@ -11,6 +11,7 @@ import {
PanelShell,
Segmented,
ViewToggle,
gridSizeStyle,
} from "@/components/panel-layout";
afterEach(cleanup);
@@ -72,6 +73,34 @@ describe("ViewToggle", () => {
fireEvent.click(screen.getByLabelText("panel.compactRows"));
expect(onDensity).toHaveBeenCalledWith("compact");
});
it("shows the columns picker only in grid view", () => {
const props = { onView: vi.fn(), columns: "auto" as const };
const { rerender } = render(
<ViewToggle view="grid" {...props} onColumns={vi.fn()} />,
);
expect(screen.getByLabelText("panel.columns.title")).toBeTruthy();
rerender(<ViewToggle view="list" {...props} onColumns={vi.fn()} />);
expect(screen.queryByLabelText("panel.columns.title")).toBeNull();
rerender(<ViewToggle view="grid" {...props} />);
expect(screen.queryByLabelText("panel.columns.title")).toBeNull();
});
});
describe("gridSizeStyle", () => {
it("fits auto cards to the pane", () => {
expect(gridSizeStyle("auto", 280).gridTemplateColumns).toBe(
"repeat(auto-fill, minmax(min(100%, 280px), 1fr))",
);
});
it("splits the width for a fixed count", () => {
expect(gridSizeStyle("4").gridTemplateColumns).toContain(
"(100% - 3 * 0.5rem) / 4",
);
});
});
describe("Facts", () => {
+7
View File
@@ -236,6 +236,13 @@ describe("plugin loader", () => {
expect(railIds()).not.toContain("headless-panel");
});
it("skips plugins safe mode keeps off", async () => {
modules.paused = railPlugin("paused");
plugins = [summary("paused", { state: "safe-mode" })];
await syncPlugins();
expect(railIds()).not.toContain("paused-panel");
});
it("keeps every plugin when the pre-login list lands after sign-in", async () => {
modules.login = railPlugin("login");
modules.alpha = railPlugin("alpha");
+2 -6
View File
@@ -183,17 +183,13 @@ describe("PluginsTab", () => {
it("switches to a table and remembers it", async () => {
await renderTab();
expect(screen.queryByRole("table")).toBeNull();
expect(
screen.getByLabelText(en.plugins.manager.columns.title),
).toBeTruthy();
expect(screen.getByLabelText(en.panel.columns.title)).toBeTruthy();
fireEvent.click(screen.getByLabelText(en.panel.listView));
expect(screen.getByRole("table")).toBeTruthy();
expect(screen.getByText(en.plugins.manager.table.version)).toBeTruthy();
expect(
screen.queryByLabelText(en.plugins.manager.columns.title),
).toBeNull();
expect(screen.queryByLabelText(en.panel.columns.title)).toBeNull();
expect(localStorage.getItem("pluginsTab.view")).toBe("list");
cleanup();
@@ -157,6 +157,7 @@ describe("pluginStatus", () => {
"disabled",
);
expect(pluginStatus(summary())).toBe("running");
expect(pluginStatus(summary({ state: "safe-mode" }))).toBe("safeMode");
expect(pluginStatus(summary({ state: "failed", runsOnServer: true }))).toBe(
"server",
);
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
const toast = vi.hoisted(() => ({ warning: vi.fn() }));
vi.mock("sonner", () => ({ toast }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const look = vi.hoisted(() => ({ safeMode: false }));
vi.mock("@/look/look-store", () => ({
useLookState: () => ({
loaded: true,
response: { safeMode: look.safeMode },
}),
}));
const { SafeModeNotice } = await import("@/shell/SafeModeNotice");
afterEach(() => {
cleanup();
toast.warning.mockReset();
});
describe("SafeModeNotice", () => {
it("shows nothing when safe mode is off", () => {
look.safeMode = false;
render(<SafeModeNotice />);
expect(toast.warning).not.toHaveBeenCalled();
});
it("shows a lasting warning when the server runs in safe mode", () => {
look.safeMode = true;
render(<SafeModeNotice />);
expect(toast.warning).toHaveBeenCalledWith(
"safeMode.title",
expect.objectContaining({
id: "safe-mode",
description: "safeMode.description",
duration: Infinity,
}),
);
});
});