mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
fix(settings): close project import edge cases
This commit is contained in:
@@ -2085,6 +2085,7 @@ export const ar: TranslationResources = {
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
unsavedChanges: "Save or discard your project changes before importing.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2099,6 +2099,7 @@ export const en = {
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
unsavedChanges: "Save or discard your project changes before importing.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2137,6 +2137,7 @@ export const es: TranslationResources = {
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
unsavedChanges: "Save or discard your project changes before importing.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2140,6 +2140,7 @@ export const fr: TranslationResources = {
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
unsavedChanges: "Save or discard your project changes before importing.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2110,6 +2110,7 @@ export const ja: TranslationResources = {
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
unsavedChanges: "Save or discard your project changes before importing.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2123,6 +2123,7 @@ export const ptBR: TranslationResources = {
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
unsavedChanges: "Save or discard your project changes before importing.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2128,6 +2128,7 @@ export const ru: TranslationResources = {
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
unsavedChanges: "Save or discard your project changes before importing.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2061,6 +2061,7 @@ export const zhCN: TranslationResources = {
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
unsavedChanges: "Save or discard your project changes before importing.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -20,6 +20,7 @@ export function ProjectConfigImportSection(input: {
|
||||
onRouteIntentConsumed?: () => void;
|
||||
projectConfigLoaded: boolean;
|
||||
projectConfigQueryKey: readonly [string, string, string];
|
||||
hasUnsavedChanges: boolean;
|
||||
}) {
|
||||
const model = useProjectConfigImportModel(input);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ProjectConfigImportIntent } from "./route";
|
||||
export type ProjectConfigImportVisibleError =
|
||||
| ProjectConfigRpcError
|
||||
| { code: "capability_missing" }
|
||||
| { code: "unsaved_changes" }
|
||||
| { code: "transport"; message: string };
|
||||
|
||||
export type ProjectConfigImportState =
|
||||
@@ -137,7 +138,7 @@ function ImportErrorRetryButton(input: {
|
||||
onApply: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (input.error.code === "capability_missing") {
|
||||
if (input.error.code === "capability_missing" || input.error.code === "unsaved_changes") {
|
||||
return null;
|
||||
}
|
||||
const needsRefresh =
|
||||
@@ -237,6 +238,8 @@ function projectConfigImportErrorText(
|
||||
return error.message;
|
||||
case "capability_missing":
|
||||
return t("settings.project.import.errors.capabilityMissing");
|
||||
case "unsaved_changes":
|
||||
return t("settings.project.import.errors.unsavedChanges");
|
||||
case "source_config_not_found":
|
||||
return t("settings.project.import.errors.notFound", { source: sourceName });
|
||||
case "invalid_source_config":
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useFetchQueries, useFetchQuery } from "@/data/query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
projectConfigImportPreviewQueryKey,
|
||||
projectConfigImportPreviewQueryInput,
|
||||
stableProjectConfigImportSourceKey,
|
||||
type ProjectConfigImportPreviewResult,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from "./sources";
|
||||
|
||||
const EMPTY_IMPORT_SOURCES: readonly ProjectConfigImportAdvertisedSource[] = [];
|
||||
type ProjectConfigImportPreviewSuccess = Extract<ProjectConfigImportPreviewResult, { ok: true }>;
|
||||
|
||||
export function useProjectConfigImportModel(input: {
|
||||
routeIntent: ProjectConfigImportIntent | null;
|
||||
@@ -38,6 +40,7 @@ export function useProjectConfigImportModel(input: {
|
||||
client: DaemonClient | null;
|
||||
projectConfigLoaded: boolean;
|
||||
projectConfigQueryKey: readonly [string, string, string];
|
||||
hasUnsavedChanges: boolean;
|
||||
registry?: ProjectConfigImportSourceRegistry;
|
||||
onRouteIntentConsumed?: () => void;
|
||||
}) {
|
||||
@@ -61,7 +64,12 @@ export function useProjectConfigImportModel(input: {
|
||||
repoRoot: input.repoRoot,
|
||||
source: intent?.source ?? null,
|
||||
protocolSource: intent?.protocolSource ?? null,
|
||||
enabled: Boolean(intent && input.projectConfigLoaded && !routeIntentCapabilityMissing),
|
||||
enabled: Boolean(
|
||||
intent &&
|
||||
input.projectConfigLoaded &&
|
||||
!routeIntentCapabilityMissing &&
|
||||
!input.hasUnsavedChanges,
|
||||
),
|
||||
});
|
||||
const preview = activePreview.data?.ok ? activePreview.data : null;
|
||||
const queryClient = useQueryClient();
|
||||
@@ -134,6 +142,14 @@ export function useProjectConfigImportModel(input: {
|
||||
repoRoot: input.repoRoot,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: projectConfigImportPreviewQueryKey(
|
||||
input.serverId,
|
||||
input.repoRoot,
|
||||
intent?.source ?? result.source,
|
||||
),
|
||||
exact: true,
|
||||
});
|
||||
const appliedSource = registry.get(result.source);
|
||||
toast.show(
|
||||
t("settings.project.import.success", {
|
||||
@@ -164,14 +180,16 @@ export function useProjectConfigImportModel(input: {
|
||||
if (!intent) {
|
||||
return null;
|
||||
}
|
||||
const error = routeIntentCapabilityMissing
|
||||
? ({ code: "capability_missing" } as const)
|
||||
: (applyError ??
|
||||
normalizeProjectConfigImportError(
|
||||
activePreview.data && !activePreview.data.ok
|
||||
? activePreview.data.error
|
||||
: activePreview.error,
|
||||
));
|
||||
const error = projectConfigImportVisibleError({
|
||||
routeIntentCapabilityMissing,
|
||||
hasUnsavedChanges: input.hasUnsavedChanges,
|
||||
applyError,
|
||||
preview,
|
||||
requestError:
|
||||
activePreview.data && !activePreview.data.ok
|
||||
? activePreview.data.error
|
||||
: activePreview.error,
|
||||
});
|
||||
if (error) {
|
||||
return {
|
||||
status: "error",
|
||||
@@ -194,6 +212,7 @@ export function useProjectConfigImportModel(input: {
|
||||
applyError,
|
||||
applyMutation.isPending,
|
||||
intent,
|
||||
input.hasUnsavedChanges,
|
||||
preview,
|
||||
retryAction,
|
||||
routeIntentCapabilityMissing,
|
||||
@@ -220,7 +239,7 @@ export function useProjectConfigImportModel(input: {
|
||||
void activePreview.refetch();
|
||||
},
|
||||
apply: () => {
|
||||
if (!applyMutation.isPending) {
|
||||
if (!applyMutation.isPending && !input.hasUnsavedChanges) {
|
||||
setApplyError(null);
|
||||
applyMutation.mutate();
|
||||
}
|
||||
@@ -228,6 +247,38 @@ export function useProjectConfigImportModel(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function projectConfigImportPreviewError(
|
||||
preview: ProjectConfigImportPreviewSuccess | null,
|
||||
): ProjectConfigRpcError | null {
|
||||
if (preview?.status === "not_found") {
|
||||
return { code: "source_config_not_found", source: preview.source };
|
||||
}
|
||||
if (preview?.status === "nothing_to_import") {
|
||||
return { code: "nothing_to_import" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectConfigImportVisibleError(input: {
|
||||
routeIntentCapabilityMissing: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
applyError: ProjectConfigImportVisibleError | null;
|
||||
preview: ProjectConfigImportPreviewSuccess | null;
|
||||
requestError: ProjectConfigRpcError | Error | null;
|
||||
}): ProjectConfigImportVisibleError | null {
|
||||
if (input.routeIntentCapabilityMissing) {
|
||||
return { code: "capability_missing" };
|
||||
}
|
||||
if (input.hasUnsavedChanges) {
|
||||
return { code: "unsaved_changes" };
|
||||
}
|
||||
return (
|
||||
input.applyError ??
|
||||
projectConfigImportPreviewError(input.preview) ??
|
||||
normalizeProjectConfigImportError(input.requestError)
|
||||
);
|
||||
}
|
||||
|
||||
export function useProjectConfigImportAvailability(input: {
|
||||
client: DaemonClient | null;
|
||||
serverId: string | null | undefined;
|
||||
|
||||
@@ -40,6 +40,7 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
applyDraftToConfig,
|
||||
configToDraft,
|
||||
hasProjectConfigDraftChanges,
|
||||
METADATA_PROMPT_KEYS,
|
||||
type LifecycleOriginalKind,
|
||||
type MetadataPromptKey,
|
||||
@@ -681,6 +682,10 @@ function ProjectConfigForm({
|
||||
const isStale = writeError?.code === "stale_project_config";
|
||||
const isWriteFailed = writeError?.code === "write_failed";
|
||||
const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts;
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() => hasProjectConfigDraftChanges({ draft, base: baseConfig }),
|
||||
[baseConfig, draft],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
@@ -697,6 +702,7 @@ function ProjectConfigForm({
|
||||
onRouteIntentConsumed={onImportIntentConsumed}
|
||||
projectConfigLoaded
|
||||
projectConfigQueryKey={queryKey}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PaseoConfigRawSchema } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
|
||||
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
|
||||
import {
|
||||
applyDraftToConfig,
|
||||
configToDraft,
|
||||
hasProjectConfigDraftChanges,
|
||||
type ProjectConfigDraft,
|
||||
} from "./project-config-form";
|
||||
|
||||
function emptyDraft(): ProjectConfigDraft {
|
||||
return {
|
||||
@@ -350,3 +355,14 @@ describe("applyDraftToConfig", () => {
|
||||
expect(Object.keys(scripts)).toEqual(["dev"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasProjectConfigDraftChanges", () => {
|
||||
it("distinguishes an untouched form from unsaved project edits", () => {
|
||||
const base = { worktree: { setup: "npm ci" } };
|
||||
const untouched = configToDraft(base);
|
||||
const edited = { ...untouched, setupText: "npm install" };
|
||||
|
||||
expect(hasProjectConfigDraftChanges({ draft: untouched, base })).toBe(false);
|
||||
expect(hasProjectConfigDraftChanges({ draft: edited, base })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PaseoMetadataGenerationEntry,
|
||||
PaseoScriptEntryRaw,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
export type LifecycleOriginalKind = "string" | "array" | "missing";
|
||||
|
||||
@@ -244,3 +245,7 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
|
||||
}
|
||||
return result as PaseoConfigRaw;
|
||||
}
|
||||
|
||||
export function hasProjectConfigDraftChanges(input: ApplyDraftInput): boolean {
|
||||
return !equal(applyDraftToConfig(input), input.base ?? {});
|
||||
}
|
||||
|
||||
+28
@@ -274,6 +274,11 @@ cwd = "./.."
|
||||
command = "npm test"
|
||||
[scripts.run.nested.options]
|
||||
cwd = "apps/web/../../.."
|
||||
|
||||
[scripts.run.unc]
|
||||
command = "npm test"
|
||||
[scripts.run.unc.options]
|
||||
cwd = '\\\\server\\share'
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -283,16 +288,39 @@ cwd = "apps/web/../../.."
|
||||
scripts: {
|
||||
parent: { command: "npm test" },
|
||||
nested: { command: "npm test" },
|
||||
unc: { command: "npm test" },
|
||||
},
|
||||
});
|
||||
expect(preview.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: "scripts.parent.cwd", outcome: "unsupported" }),
|
||||
expect.objectContaining({ key: "scripts.nested.cwd", outcome: "unsupported" }),
|
||||
expect.objectContaining({ key: "scripts.unc.cwd", outcome: "unsupported" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("rewrites Conductor ports inside shell parameter expansions", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(
|
||||
repo,
|
||||
`
|
||||
[scripts.run.dev]
|
||||
command = "npm run dev -- --port \${CONDUCTOR_PORT:-3000}"
|
||||
`,
|
||||
);
|
||||
|
||||
expect(inspect(repo).preview).toMatchObject({
|
||||
scripts: {
|
||||
dev: {
|
||||
type: "service",
|
||||
port: "$PASEO_PORT",
|
||||
command: "npm run dev -- --port ${PASEO_PORT:-3000}",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not import scripts available only in Conductor cloud", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(
|
||||
|
||||
+9
-4
@@ -383,12 +383,12 @@ function rewriteVariables(
|
||||
}
|
||||
|
||||
function replaceShellVariable(command: string, from: string, to: string): string {
|
||||
const pattern = new RegExp(`\\$(?:\\{${from}\\}|${from}(?![A-Za-z0-9_]))`, "g");
|
||||
return command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}}` : `$${to}`));
|
||||
const pattern = new RegExp(`\\$\\{${from}(?=[}:#%+\\-=?])|\\$${from}(?![A-Za-z0-9_])`, "g");
|
||||
return command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}` : `$${to}`));
|
||||
}
|
||||
|
||||
function containsShellVariable(command: string, name: string): boolean {
|
||||
const pattern = new RegExp(`\\$(?:\\{${name}\\}|${name}(?![A-Za-z0-9_]))`);
|
||||
const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`);
|
||||
return pattern.test(command);
|
||||
}
|
||||
|
||||
@@ -401,7 +401,12 @@ function appendArgs(command: string, args: string[]): string {
|
||||
|
||||
function safeCwdPrefix(cwd: string): string | null {
|
||||
const normalized = posix.normalize(cwd.replaceAll("\\", "/"));
|
||||
if (/^(?:\/|[A-Za-z]:[\\/])/.test(cwd) || normalized === ".." || normalized.startsWith("../")) {
|
||||
if (
|
||||
normalized.startsWith("/") ||
|
||||
/^(?:\/|[A-Za-z]:[\\/])/.test(cwd) ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `cd -- ${shellQuote(cwd)} && `;
|
||||
|
||||
Reference in New Issue
Block a user