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 ( - { - if (!next) finish(true); - }} - > - e.preventDefault()} - > - - - {t(step.titleKey)} - - - {t("onboarding.stepCounter", { - current: clamped + 1, - total: steps.length, - })} - - - -
-
-
- -
- -
- -
-
- {steps.map((s, i) => ( -
- -
- {!isLast && ( - - )} - {clamped > 0 && ( - - )} - -
-
- -
- ); -} 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.doneHeading")} - - - {t("onboarding.doneDesc")} - -
-
- -
- {NEXT_STEPS.map((step) => { - const { icon: Icon, key } = step; - const href = "href" in step ? step.href : null; - return ( -
- -
- - {t(`onboarding.done_${key}`)} - - - {t(`onboarding.done_${key}_desc`)} - - {href && ( - - {t(`onboarding.done_${key}_link`)} - - )} -
-
- ); - })} -
-
- ); -} diff --git a/src/ui/onboarding/steps/FeaturesStep.tsx b/src/ui/onboarding/steps/FeaturesStep.tsx deleted file mode 100644 index c9f4e70d6..000000000 --- a/src/ui/onboarding/steps/FeaturesStep.tsx +++ /dev/null @@ -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 ( -
-

- {t("onboarding.featuresIntro")} -

- -
- {features.map(({ icon: Icon, id, title, description }) => ( -
- {Icon && ( - - )} -
- {title} - - {description} - -
-
- ))} -
- -

- {t("onboarding.featuresRailHint")} -

-
- ); -} diff --git a/src/ui/onboarding/steps/WelcomeStep.tsx b/src/ui/onboarding/steps/WelcomeStep.tsx deleted file mode 100644 index 0e10ff0c0..000000000 --- a/src/ui/onboarding/steps/WelcomeStep.tsx +++ /dev/null @@ -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 ( -
-

- {t("onboarding.welcomeIntro")} -

- -
- {HIGHLIGHTS.map(({ icon: Icon, titleKey, descKey }) => ( -
- -
- {t(titleKey)} - - {t(descKey)} - -
-
- ))} -
-
- ); -} diff --git a/src/ui/onboarding/steps/WorkflowStep.tsx b/src/ui/onboarding/steps/WorkflowStep.tsx deleted file mode 100644 index 66b3f34e3..000000000 --- a/src/ui/onboarding/steps/WorkflowStep.tsx +++ /dev/null @@ -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 ( -
-

- {t("onboarding.workflowIntro")} -

- -
-
- -
- - {t("onboarding.workflow_palette")} - - - {t("onboarding.workflow_palette_desc")} - - - Shift - Shift - -
-
- - {tips.map(({ icon: Icon, id, title, description }) => ( -
- {Icon && ( - - )} -
- {title} - - {description} - -
-
- ))} -
-
- ); -}