fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

This commit is contained in:
LukeGus committed 2026-10-06 17:44:17 -05:00
1 parent 2d71e886ac
commit fb904554e8
8 files changed
+57 -407

No files matched your search

+29
View File
@@ -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
View File
@@ -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}`);
+26
View File
@@ -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 = [];
-158
View File
@@ -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>
);
}
-72
View File
@@ -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>
);
}
-52
View File
@@ -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>
);
}
-49
View File
@@ -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>
);
}
-74
View File
@@ -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>
);
}