feat: shell controller, TabStage, layout and theme plugins with repair view

This commit is contained in:
LukeGus committed 2026-10-18 16:37:36 -05:00
1 parent aff29a9581
commit 86161d66e2
120 files changed
+23693 -3381

No files matched your search

+68
View File
@@ -416,3 +416,71 @@ describe("rebuilding local plugins", () => {
expect(run.sort()).toEqual(["a", "b"]);
});
});
describe("default look", () => {
const entry = { source: "tmxplug", path: "a", sha256: SHA };
it("reads the defaultLook flag and refuses anything but a boolean", () => {
const { plugins, problems } = parseBundledPlugins({
plugins: [
{ ...entry, id: "look", defaultLook: true },
{ ...entry, id: "plain" },
{ ...entry, id: "odd", defaultLook: "yes" },
],
});
expect(problems).toEqual(["odd defaultLook must be a boolean"]);
expect(
plugins.map((p: { id: string; defaultLook: boolean }) => [
p.id,
p.defaultLook,
]),
).toEqual([
["look", true],
["plain", false],
]);
});
it("carries it into the bundled index and through a re-pin", () => {
expect(
buildBundledIndex(
[
{ id: "look", onboarding: null, defaultLook: true },
{ id: "plain", onboarding: null, defaultLook: false },
],
["look", "plain"],
),
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
const index = {
plugins: [
{
id: "look",
versions: [
{ version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA },
],
},
],
};
expect(
pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0],
).toMatchObject({ id: "look", defaultLook: true });
});
it("copies a local build's theme files", () => {
const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
fs.mkdirSync(path.join(repo, "themes"), { recursive: true });
fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}");
fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}");
fs.writeFileSync(
path.join(repo, "manifest.json"),
JSON.stringify({
id: "inks",
contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] },
}),
);
const out = path.join(tempDir(), "inks");
copyLocalBuild(repo, out);
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
});
});
+2
View File
@@ -194,6 +194,7 @@ const ALLOWED = [
"SheetHeader",
"SheetTitle",
"SheetTrigger",
"ShellConfirmCapture",
"Skeleton",
"Slider",
"Sparkline",
@@ -204,6 +205,7 @@ const ALLOWED = [
"Switch",
"SwitchRow",
"TOTPDialog",
"TabStage",
"TabStrip",
"TabStripItem",
"TagInput",
+38 -3
View File
@@ -15,6 +15,11 @@
* enabledByEnv counts the plugin as recommended when any of those
* environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked.
*
* "defaultLook": true marks the plugin whose layout (and that layout's
* theme) a fresh install shows. Core finds the default look this way, so it
* never names a plugin. Such a plugin always starts enabled and cannot be
* uninstalled, only swapped for another look.
*/
const crypto = require("node:crypto");
@@ -110,6 +115,12 @@ function parseBundledPlugins(raw) {
}
const before = problems.length;
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
if (
entry.defaultLook !== undefined &&
typeof entry.defaultLook !== "boolean"
) {
problems.push(`${entry.id} defaultLook must be a boolean`);
}
if (problems.length > before) continue;
plugins.push({
id: entry.id,
@@ -118,6 +129,7 @@ function parseBundledPlugins(raw) {
path: hasPath ? entry.path : null,
sha256: entry.sha256,
onboarding,
defaultLook: entry.defaultLook === true,
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -218,8 +230,12 @@ function buildBundledIndex(plugins, stagedIds) {
const staged = new Set(stagedIds);
const out = {};
for (const plugin of plugins) {
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
out[plugin.id] = { onboarding: plugin.onboarding };
if (!staged.has(plugin.id)) continue;
if (!plugin.onboarding && !plugin.defaultLook) continue;
out[plugin.id] = {
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
...(plugin.defaultLook ? { defaultLook: true } : {}),
};
}
return { version: 1, plugins: out };
}
@@ -238,12 +254,14 @@ function pinsFromIndex(index, ids, current = []) {
const latest = byId.get(id)?.versions?.[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true;
return {
id,
source: "tmxplug",
url: latest.url,
sha256: latest.sha256,
...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}),
};
});
}
@@ -357,9 +375,26 @@ function rebuildLocalPlugins(dir, ids, run) {
}
/** Copies a local plugin build into the staging folder. */
/** Theme CSS files the repo's manifest declares, which ship with it too. */
function localThemeFiles(repo) {
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
);
const themes = manifest?.contributes?.themes;
return Array.isArray(themes)
? themes
.map((theme) => theme?.css)
.filter((css) => typeof css === "string")
: [];
} catch {
return [];
}
}
function copyLocalBuild(repo, destination) {
fs.mkdirSync(destination, { recursive: true });
for (const entry of LOCAL_FILES) {
for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) {
const from = path.join(repo, entry);
if (fs.existsSync(from)) {
fs.cpSync(from, path.join(destination, entry), { recursive: true });
+26
View File
@@ -142,3 +142,29 @@ describe("termix-plugin pack", () => {
expect(fs.readdirSync(out)).toEqual([]);
});
});
describe("termix-plugin pack themes", () => {
it("ships the CSS files the manifest's themes declare", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
const dir = fixturePlugin({
capabilities: ["ui:theme"],
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "styles/paper.css",
},
],
},
});
writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n");
writeFile(dir, "styles/draft.css", ".draft {}\n");
const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] });
const names = await entries(file);
expect(names).toContain("styles/paper.css");
expect(names).not.toContain("styles/draft.css");
});
});
+50
View File
@@ -49,3 +49,53 @@ describe("termix-plugin validate migrations", () => {
expect(output).not.toMatch(/migrations\/\w+ is missing/);
});
});
describe("termix-plugin validate themes", () => {
function themed(css: string | null) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
id: "fixture",
version: "1.0.0",
capabilities: ["ui:theme"],
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
},
}),
);
if (css !== null) {
fs.mkdirSync(path.join(root, "themes"));
fs.writeFileSync(path.join(root, "themes", "paper.css"), css);
}
return root;
}
it("reports a missing theme file", async () => {
expect(await problems(themed(null))).toContain(
"theme paper: themes/paper.css does not exist",
);
});
it("reports theme CSS that reaches outside or leaves its class", async () => {
const output = await problems(
themed('@import "x.css"; body { color: red }'),
);
expect(output).toContain("theme paper: @import is not allowed in a theme");
expect(output).toContain(
'theme paper: Selector "body" must start with .paper',
);
});
it("says nothing about CSS that is fine", async () => {
const output = await problems(themed(".paper { --background: #fff; }"));
expect(output).not.toContain("theme paper");
});
});