mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 21:32:38 +00:00
fix: stop the dev runner rebuilding plugins when windows reports file reads as changes
This commit is contained in:
1 parent
2d71e886ac
commit
fb904554e8
8 files changed
+57
-407
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
createSerialQueue,
|
||||
dataDirFor,
|
||||
findBuildablePlugins,
|
||||
isPluginEdit,
|
||||
isPluginSourceChange,
|
||||
needsDocker,
|
||||
parseDevArgs,
|
||||
@@ -46,6 +47,34 @@ describe("isPluginSourceChange", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginEdit", () => {
|
||||
function repo() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-edit-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(dir, "src", "frontend"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "src", "frontend", "index.ts"), "");
|
||||
return dir;
|
||||
}
|
||||
|
||||
it("ignores a folder being read", () => {
|
||||
expect(isPluginEdit(repo(), "src/frontend", () => true)).toBe(false);
|
||||
});
|
||||
|
||||
it("rebuilds an edited file only while the build is out of date", () => {
|
||||
const dir = repo();
|
||||
expect(isPluginEdit(dir, "src/frontend/index.ts", () => true)).toBe(true);
|
||||
expect(isPluginEdit(dir, "src/frontend/index.ts", () => false)).toBe(false);
|
||||
});
|
||||
|
||||
it("rebuilds after a source file is deleted", () => {
|
||||
expect(isPluginEdit(repo(), "src/gone.ts", () => false)).toBe(true);
|
||||
});
|
||||
|
||||
it("skips paths that do not feed the build", () => {
|
||||
expect(isPluginEdit(repo(), "dist/index.js", () => true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findBuildablePlugins", () => {
|
||||
it("lists repos with a manifest and installed dependencies", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-dev-"));
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ import {
|
||||
createSerialQueue,
|
||||
dataDirFor,
|
||||
findBuildablePlugins,
|
||||
isPluginSourceChange,
|
||||
isPluginEdit,
|
||||
needsDocker,
|
||||
parseDevArgs,
|
||||
parseTscStatus,
|
||||
@@ -575,7 +575,7 @@ function watchPlugins(dir) {
|
||||
);
|
||||
for (const plugin of plugins) {
|
||||
watch(plugin.repo, true, (_event, filename) => {
|
||||
if (isPluginSourceChange(filename)) changed(plugin.id);
|
||||
if (isPluginEdit(plugin.repo, filename)) changed(plugin.id);
|
||||
});
|
||||
}
|
||||
log(`watching ${plugins.length} plugin(s) in ${dir}`);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const { isLocalBuildStale } = createRequire(import.meta.url)(
|
||||
"./bundled-plugins.cjs",
|
||||
);
|
||||
|
||||
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const SOURCE_DIRS = new Set(["src", "locales", "migrations"]);
|
||||
@@ -19,6 +24,27 @@ export function isPluginSourceChange(filename) {
|
||||
return SOURCE_DIRS.has(parts[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a watcher event in a plugin repo is an edit worth a rebuild.
|
||||
*
|
||||
* On Windows, fs.watch also reports a folder or file being read (its last
|
||||
* access time changing, which NTFS writes lazily), so reading a repo looks
|
||||
* like editing it. Folder events are ignored, and a file event only counts
|
||||
* while its sources are newer than the last build. A path that no longer
|
||||
* exists was deleted, which always counts.
|
||||
*/
|
||||
export function isPluginEdit(repo, filename, isStale = isLocalBuildStale) {
|
||||
if (!isPluginSourceChange(filename)) return false;
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(path.join(repo, String(filename)));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (stat.isDirectory()) return false;
|
||||
return isStale(repo);
|
||||
}
|
||||
|
||||
/** Plugin repos under dir that can be built (manifest.json and node_modules). */
|
||||
export function findBuildablePlugins(dir) {
|
||||
const plugins = [];
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import {
|
||||
relevantSteps,
|
||||
type OnboardingContext,
|
||||
type OnboardingStep,
|
||||
} from "./onboarding-steps";
|
||||
|
||||
/** Plugin steps go in before this one. */
|
||||
const PLUGIN_STEPS_BEFORE = "security";
|
||||
|
||||
export function OnboardingDialog({
|
||||
open,
|
||||
context,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
context: OnboardingContext;
|
||||
onClose: (skipped: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useUiPreferencesContext();
|
||||
const pluginSteps = useActionSlot("onboarding.steps");
|
||||
const steps = useMemo(() => {
|
||||
const core = relevantSteps(context);
|
||||
const extra: OnboardingStep[] = pluginSteps
|
||||
.filter((contribution) => contribution.component)
|
||||
.map((contribution) => ({
|
||||
id: contribution.actionId,
|
||||
titleKey: contribution.titleKey,
|
||||
Component:
|
||||
contribution.component as unknown as OnboardingStep["Component"],
|
||||
}));
|
||||
const at = core.findIndex((step) => step.id === PLUGIN_STEPS_BEFORE);
|
||||
return at < 0
|
||||
? [...core, ...extra]
|
||||
: [...core.slice(0, at), ...extra, ...core.slice(at)];
|
||||
}, [context, pluginSteps]);
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
// A reopened run always starts from the top.
|
||||
useEffect(() => {
|
||||
if (open) setIndex(0);
|
||||
}, [open]);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
const clamped = Math.min(index, steps.length - 1);
|
||||
const step = steps[clamped];
|
||||
const isLast = clamped === steps.length - 1;
|
||||
const StepComponent = step.Component;
|
||||
|
||||
function finish(skipped: boolean) {
|
||||
ctx?.completeOnboarding(skipped);
|
||||
onClose(skipped);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) finish(true);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-xl max-h-[85vh] overflow-y-auto"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold">
|
||||
{t(step.titleKey)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("onboarding.stepCounter", {
|
||||
current: clamped + 1,
|
||||
total: steps.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-0.5 w-full bg-muted-foreground/15">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-200"
|
||||
style={{ width: `${((clamped + 1) / steps.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<StepComponent context={context} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{steps.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
title={t(s.titleKey)}
|
||||
aria-label={t(s.titleKey)}
|
||||
aria-current={i === clamped}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-colors ${
|
||||
i === clamped
|
||||
? "bg-accent-brand"
|
||||
: "bg-muted-foreground/25 hover:bg-muted-foreground/50"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isLast && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-[11px]"
|
||||
onClick={() => finish(true)}
|
||||
>
|
||||
{t("onboarding.skip")}
|
||||
</Button>
|
||||
)}
|
||||
{clamped > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[11px]"
|
||||
onClick={() => setIndex(clamped - 1)}
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => (isLast ? finish(false) : setIndex(clamped + 1))}
|
||||
>
|
||||
{isLast ? t("onboarding.finish") : t("onboarding.next")}
|
||||
{!isLast && <ArrowRight size={12} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
Monitor,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Smartphone,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
|
||||
const NEXT_STEPS = [
|
||||
{ icon: Settings, key: "settings" },
|
||||
{ icon: RotateCcw, key: "rerun" },
|
||||
{ icon: Monitor, key: "desktop", href: "https://docs.termix.site/install" },
|
||||
{ icon: Smartphone, key: "mobile", href: "https://docs.termix.site/install" },
|
||||
{ icon: BookOpen, key: "docs", href: "https://docs.termix.site" },
|
||||
{ icon: Terminal, key: "cli", href: "https://docs.termix.site/cli" },
|
||||
] as const;
|
||||
|
||||
export function DoneStep() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col items-center gap-2 py-2 text-center">
|
||||
<CheckCircle2 size={26} className="text-accent-brand" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold">
|
||||
{t("onboarding.doneHeading")}
|
||||
</span>
|
||||
<span className="max-w-sm text-[11px] leading-snug text-muted-foreground">
|
||||
{t("onboarding.doneDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{NEXT_STEPS.map((step) => {
|
||||
const { icon: Icon, key } = step;
|
||||
const href = "href" in step ? step.href : null;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
|
||||
>
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t(`onboarding.done_${key}`)}
|
||||
</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t(`onboarding.done_${key}_desc`)}
|
||||
</span>
|
||||
{href && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-0.5 w-fit text-[10px] font-medium text-accent-brand hover:underline"
|
||||
>
|
||||
{t(`onboarding.done_${key}_link`)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
|
||||
/**
|
||||
* A tour of the things people miss because they live behind a rail icon.
|
||||
* Terminal and hosts are covered by the welcome step, so this is deliberately
|
||||
* the "there is more than SSH here" list. Every entry comes from a plugin,
|
||||
* through the "onboarding.features" slot.
|
||||
*/
|
||||
|
||||
export function FeaturesStep() {
|
||||
const { t } = useTranslation();
|
||||
const pluginFeatures = useActionSlot("onboarding.features");
|
||||
const features = pluginFeatures.map((feature) => ({
|
||||
id: feature.actionId,
|
||||
icon: feature.icon as LucideIcon | undefined,
|
||||
title: t(feature.titleKey),
|
||||
description: feature.descriptionKey ? t(feature.descriptionKey) : "",
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.featuresIntro")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{features.map(({ icon: Icon, id, title, description }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
|
||||
>
|
||||
{Icon && (
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">{title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{t("onboarding.featuresRailHint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderTree, Server, TerminalSquare } from "lucide-react";
|
||||
|
||||
const HIGHLIGHTS = [
|
||||
{
|
||||
icon: Server,
|
||||
titleKey: "onboarding.welcome_hosts",
|
||||
descKey: "onboarding.welcome_hosts_desc",
|
||||
},
|
||||
{
|
||||
icon: TerminalSquare,
|
||||
titleKey: "onboarding.welcome_terminal",
|
||||
descKey: "onboarding.welcome_terminal_desc",
|
||||
},
|
||||
{
|
||||
icon: FolderTree,
|
||||
titleKey: "onboarding.welcome_files",
|
||||
descKey: "onboarding.welcome_files_desc",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function WelcomeStep() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.welcomeIntro")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{HIGHLIGHTS.map(({ icon: Icon, titleKey, descKey }) => (
|
||||
<div
|
||||
key={titleKey}
|
||||
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
|
||||
>
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">{t(titleKey)}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t(descKey)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Columns2, PanelRight, Search, type LucideIcon } from "lucide-react";
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
|
||||
/**
|
||||
* The handful of navigation habits that make the app feel fast. Each row shows
|
||||
* the actual gesture rather than describing it, so it reads as a cheat sheet
|
||||
* people can come back to via "Run setup again".
|
||||
*/
|
||||
export function WorkflowStep() {
|
||||
const { t } = useTranslation();
|
||||
const pluginTips = useActionSlot("onboarding.workflow");
|
||||
const tips = [
|
||||
...[
|
||||
{ icon: Columns2, key: "split" },
|
||||
{ icon: PanelRight, key: "dock" },
|
||||
].map(({ icon, key }) => ({
|
||||
id: key,
|
||||
icon: icon as LucideIcon | undefined,
|
||||
title: t(`onboarding.workflow_${key}`),
|
||||
description: t(`onboarding.workflow_${key}_desc`),
|
||||
})),
|
||||
...pluginTips.map((tip) => ({
|
||||
id: tip.actionId,
|
||||
icon: tip.icon as LucideIcon | undefined,
|
||||
title: t(tip.titleKey),
|
||||
description: tip.descriptionKey ? t(tip.descriptionKey) : "",
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.workflowIntro")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-start gap-2.5 border border-border bg-card p-2.5">
|
||||
<Search size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
{t("onboarding.workflow_palette")}
|
||||
</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t("onboarding.workflow_palette_desc")}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1">
|
||||
<Kbd className="h-5 rounded-none bg-background px-1.5">Shift</Kbd>
|
||||
<Kbd className="h-5 rounded-none bg-background px-1.5">Shift</Kbd>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tips.map(({ icon: Icon, id, title, description }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
|
||||
>
|
||||
{Icon && (
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">{title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user