mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +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,
|
createSerialQueue,
|
||||||
dataDirFor,
|
dataDirFor,
|
||||||
findBuildablePlugins,
|
findBuildablePlugins,
|
||||||
|
isPluginEdit,
|
||||||
isPluginSourceChange,
|
isPluginSourceChange,
|
||||||
needsDocker,
|
needsDocker,
|
||||||
parseDevArgs,
|
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", () => {
|
describe("findBuildablePlugins", () => {
|
||||||
it("lists repos with a manifest and installed dependencies", () => {
|
it("lists repos with a manifest and installed dependencies", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-dev-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-dev-"));
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ import {
|
|||||||
createSerialQueue,
|
createSerialQueue,
|
||||||
dataDirFor,
|
dataDirFor,
|
||||||
findBuildablePlugins,
|
findBuildablePlugins,
|
||||||
isPluginSourceChange,
|
isPluginEdit,
|
||||||
needsDocker,
|
needsDocker,
|
||||||
parseDevArgs,
|
parseDevArgs,
|
||||||
parseTscStatus,
|
parseTscStatus,
|
||||||
@@ -575,7 +575,7 @@ function watchPlugins(dir) {
|
|||||||
);
|
);
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
watch(plugin.repo, true, (_event, filename) => {
|
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}`);
|
log(`watching ${plugins.length} plugin(s) in ${dir}`);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
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 ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
const SOURCE_DIRS = new Set(["src", "locales", "migrations"]);
|
const SOURCE_DIRS = new Set(["src", "locales", "migrations"]);
|
||||||
@@ -19,6 +24,27 @@ export function isPluginSourceChange(filename) {
|
|||||||
return SOURCE_DIRS.has(parts[0]);
|
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). */
|
/** Plugin repos under dir that can be built (manifest.json and node_modules). */
|
||||||
export function findBuildablePlugins(dir) {
|
export function findBuildablePlugins(dir) {
|
||||||
const plugins = [];
|
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