diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 432e3ee65..5f17c057f 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -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-")); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 37fa96cbd..6d8ba5a8d 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -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}`); diff --git a/scripts/lib/dev-runner.mjs b/scripts/lib/dev-runner.mjs index a2b6362dd..20128c757 100644 --- a/scripts/lib/dev-runner.mjs +++ b/scripts/lib/dev-runner.mjs @@ -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 = []; diff --git a/src/ui/onboarding/OnboardingDialog.tsx b/src/ui/onboarding/OnboardingDialog.tsx deleted file mode 100644 index 25e47409a..000000000 --- a/src/ui/onboarding/OnboardingDialog.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/ui/onboarding/steps/DoneStep.tsx b/src/ui/onboarding/steps/DoneStep.tsx deleted file mode 100644 index 51288146d..000000000 --- a/src/ui/onboarding/steps/DoneStep.tsx +++ /dev/null @@ -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 ( -
- {t("onboarding.featuresIntro")} -
- -- {t("onboarding.featuresRailHint")} -
-- {t("onboarding.welcomeIntro")} -
- -- {t("onboarding.workflowIntro")} -
- -