Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 4 additions & 18 deletions apps/desktop/src/lib/trpc/routers/migration/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
projects,
v1MigrationState,
workspaceSections,
workspaces,
worktrees,
} from "@superset/local-db";
Expand All @@ -12,7 +11,7 @@ import { publicProcedure, router } from "../..";

const migrationStateRowSchema = z.object({
v1Id: z.string().min(1),
kind: z.enum(["project", "workspace"]),
kind: z.enum(["project", "workspace", "preset"]),
v2Id: z.string().nullable(),
organizationId: z.string().min(1),
status: z.enum(["success", "linked", "error", "skipped"]),
Expand All @@ -22,9 +21,9 @@ const migrationStateRowSchema = z.object({
export const createMigrationRouter = () => {
return router({
readV1Projects: publicProcedure.query(() => {
// Only migrate pinned projects. v1's `hideProject` nulls tab_order when
// the last workspace in a project is deleted, effectively abandoning the
// project — don't resurrect those in v2.
// Only surface pinned projects. v1's `hideProject` nulls tab_order
// when the last workspace in a project is deleted, effectively
// abandoning the project — don't resurrect those in v2.
return localDb
.select()
.from(projects)
Expand All @@ -44,10 +43,6 @@ export const createMigrationRouter = () => {
return localDb.select().from(worktrees).all();
}),

readV1WorkspaceSections: publicProcedure.query(() => {
return localDb.select().from(workspaceSections).all();
}),

listState: publicProcedure
.input(z.object({ organizationId: z.string().min(1) }))
.query(({ input }) => {
Expand Down Expand Up @@ -87,14 +82,5 @@ export const createMigrationRouter = () => {
})
.run();
}),

clearState: publicProcedure
.input(z.object({ organizationId: z.string().min(1) }))
.mutation(({ input }) => {
localDb
.delete(v1MigrationState)
.where(eq(v1MigrationState.organizationId, input.organizationId))
.run();
}),
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Button } from "@superset/ui/button";
import { useEffect, useState } from "react";
import { LuArrowRight, LuX } from "react-icons/lu";
import { env } from "renderer/env.renderer";
import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled";
import { authClient } from "renderer/lib/auth-client";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useOpenV1ImportModal } from "renderer/stores/v1-import-modal";
import { MOCK_ORG_ID } from "shared/constants";

const DISMISS_SESSION_KEY_PREFIX = "v1-import-banner-dismissed";

function dismissKey(organizationId: string): string {
return `${DISMISS_SESSION_KEY_PREFIX}:${organizationId}`;
}

function readDismissed(organizationId: string | null): boolean {
if (!organizationId || typeof window === "undefined") return false;
return sessionStorage.getItem(dismissKey(organizationId)) === "1";
}

export function V1ImportBanner() {
const { data: session } = authClient.useSession();
const isV2CloudEnabled = useIsV2CloudEnabled();
const organizationId = env.SKIP_ENV_VALIDATION
? MOCK_ORG_ID
: (session?.session?.activeOrganizationId ?? null);
const openModal = useOpenV1ImportModal();
const [dismissed, setDismissed] = useState(() =>
readDismissed(organizationId),
);

// Re-sync local state when the active org changes — dismissal is per
// org, so flipping orgs should reveal the banner again if it hasn't
// been dismissed there yet.
useEffect(() => {
setDismissed(readDismissed(organizationId));
}, [organizationId]);

const projectsQuery = electronTrpc.migration.readV1Projects.useQuery(
undefined,
{ enabled: isV2CloudEnabled && !!organizationId && !dismissed },
);
const auditQuery = electronTrpc.migration.listState.useQuery(
{ organizationId: organizationId ?? "" },
{ enabled: isV2CloudEnabled && !!organizationId && !dismissed },
);

if (!isV2CloudEnabled || !organizationId || dismissed) return null;

const projects = projectsQuery.data ?? [];
const importedV1Ids = new Set(
(auditQuery.data ?? [])
.filter(
(row) =>
row.kind === "project" &&
(row.status === "success" || row.status === "linked"),
)
.map((row) => row.v1Id),
);
const remaining = projects.filter((p) => !importedV1Ids.has(p.id)).length;

if (remaining === 0) return null;

const dismiss = () => {
if (organizationId) {
sessionStorage.setItem(dismissKey(organizationId), "1");
}
setDismissed(true);
};

return (
<div className="flex items-center gap-3 border-b bg-muted/30 px-5 py-2">
<div className="flex-1 text-sm text-foreground">
You have{" "}
<span className="font-medium">
{remaining} v1 project{remaining === 1 ? "" : "s"}
</span>{" "}
you can bring over to v2.
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => openModal()}
className="gap-1.5"
>
Import from v1
<LuArrowRight className="size-3.5" strokeWidth={2} />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={dismiss}
aria-label="Dismiss"
className="h-7 w-7"
>
<LuX className="size-3.5" strokeWidth={2} />
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { V1ImportBanner } from "./V1ImportBanner";
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { useHotkey } from "renderer/hotkeys";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar";
import { useDevSeedV2Sidebar } from "renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar";
import { useMigrateV1DataToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1DataToV2";
import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel";
import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar";
import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components";
Expand All @@ -24,6 +23,7 @@ import {
} from "renderer/stores/workspace-sidebar-state";
import { AddRepositoryModals } from "./components/AddRepositoryModals";
import { TopBar } from "./components/TopBar";
import { V1ImportBanner } from "./components/V1ImportBanner";

export const Route = createFileRoute("/_authenticated/_dashboard")({
component: DashboardLayout,
Expand All @@ -34,7 +34,6 @@ function DashboardLayout() {
const openNewWorkspaceModal = useOpenNewWorkspaceModal();
const isV2CloudEnabled = useIsV2CloudEnabled();
useDevSeedV2Sidebar();
useMigrateV1DataToV2();
// Get current workspace from route to pre-select project in new workspace modal
const matchRoute = useMatchRoute();
const currentWorkspaceMatch = matchRoute({
Expand Down Expand Up @@ -133,6 +132,7 @@ function DashboardLayout() {
{sidebarOutsideColumn && sidebarPanel}
<div className="flex flex-1 flex-col min-w-0 min-h-0">
<TopBar />
<V1ImportBanner />
<div className="flex flex-1 min-h-0 min-w-0 overflow-hidden">
{!sidebarOutsideColumn && sidebarPanel}
<div className="flex flex-1 min-h-0 min-w-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
} from "renderer/assets/app-icons/preset-icons";
import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut";
import type { HotkeyId } from "renderer/hotkeys";
import { useMigrateV1PresetsToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal";
import { V2PresetBarItem } from "./components/V2PresetBarItem";
Expand Down Expand Up @@ -68,7 +67,6 @@ export function V2PresetsBar({
const navigate = useNavigate();
const isDark = useIsDarkTheme();
const collections = useCollections();
useMigrateV1PresetsToV2();

const [localVisiblePresetIds, setLocalVisiblePresetIds] = useState<string[]>(
() => getVisiblePresetOrder(matchedPresets),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import type { TerminalPreset } from "@superset/local-db";
import {
AGENT_LABELS,
AGENT_TYPES,
type AgentType,
} from "@superset/shared/agent-command";
import { useState } from "react";
import { LuTerminal } from "react-icons/lu";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal";
import { ImportPageShell } from "../components/ImportPageShell";
import { ImportRow, type RowAction } from "../components/ImportRow";

interface ImportPresetsPageProps {
organizationId: string;
}

interface AuditLogEntry {
v2Id: string | null;
status: string;
reason: string | null;
}

const BUILTIN_AGENT_IDS = new Set<string>(AGENT_TYPES);

export function ImportPresetsPage({ organizationId }: ImportPresetsPageProps) {
const presetsQuery = electronTrpc.settings.getTerminalPresets.useQuery();
const auditQuery = electronTrpc.migration.listState.useQuery({
organizationId,
});
const [isRefreshing, setIsRefreshing] = useState(false);

const isLoading = presetsQuery.isPending || auditQuery.isPending;
const presets = presetsQuery.data ?? [];

const auditByV1Id = new Map<string, AuditLogEntry>();
for (const row of auditQuery.data ?? []) {
if (row.kind !== "preset") continue;
auditByV1Id.set(row.v1Id, {
v2Id: row.v2Id,
status: row.status,
reason: row.reason,
});
}

const refresh = async () => {
setIsRefreshing(true);
try {
await Promise.all([presetsQuery.refetch(), auditQuery.refetch()]);
} finally {
setIsRefreshing(false);
}
};

return (
<ImportPageShell
title="Bring over your terminal presets"
description="Import each v1 terminal preset into v2."
isLoading={isLoading}
itemCount={presets.length}
emptyMessage="No v1 terminal presets found."
onRefresh={refresh}
isRefreshing={isRefreshing}
>
{presets.map((preset, index) => (
<PresetRow
key={preset.id}
preset={preset}
tabOrder={index}
audit={auditByV1Id.get(preset.id)}
organizationId={organizationId}
/>
))}
</ImportPageShell>
);
}

interface PresetRowProps {
preset: TerminalPreset;
tabOrder: number;
audit: AuditLogEntry | undefined;
organizationId: string;
}

function PresetRow({
preset,
tabOrder,
audit,
organizationId,
}: PresetRowProps) {
const collections = useCollections();
const upsertState = electronTrpc.migration.upsertState.useMutation();
const trpcUtils = electronTrpc.useUtils();
const [running, setRunning] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const auditImported = audit !== undefined && audit.status === "success";
const auditError =
audit !== undefined && audit.status === "error" ? audit.reason : null;

const runImport = async () => {
setRunning(true);
setErrorMessage(null);
try {
const linkedAgentId: AgentType | undefined = BUILTIN_AGENT_IDS.has(
preset.name,
)
? (preset.name as AgentType)
: undefined;

// Reuse the audit row's v2Id when present so a retry after a
// partial failure (insert succeeded, audit upsert failed) doesn't
// create a duplicate v2 preset row. Insert is upsert-by-id, so
// re-running with the same id is a no-op.
const v2Id = audit?.v2Id ?? crypto.randomUUID();
const row: V2TerminalPresetRow = {
id: v2Id,
name: linkedAgentId ? AGENT_LABELS[linkedAgentId] : preset.name,
description: preset.description,
cwd: preset.cwd,
commands: preset.commands,
projectIds: preset.projectIds ?? null,
pinnedToBar: preset.pinnedToBar,
applyOnWorkspaceCreated: preset.applyOnWorkspaceCreated,
applyOnNewTab: preset.applyOnNewTab,
executionMode: preset.executionMode ?? "new-tab",
tabOrder,
createdAt: new Date(),
agentId: linkedAgentId,
};
collections.v2TerminalPresets.insert(row);

await upsertState.mutateAsync({
v1Id: preset.id,
kind: "preset",
v2Id,
organizationId,
status: "success",
reason: null,
});

await trpcUtils.migration.listState.invalidate({ organizationId });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setErrorMessage(message);
await upsertState
.mutateAsync({
v1Id: preset.id,
kind: "preset",
v2Id: null,
organizationId,
status: "error",
reason: message,
})
.catch((auditErr) => {
console.warn(
"[v1-import] failed to record preset import error in audit",
{ presetId: preset.id, auditErr },
);
});
await trpcUtils.migration.listState.invalidate({ organizationId });
} finally {
setRunning(false);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const action: RowAction = (() => {
if (running) return { kind: "running" };
if (auditImported) return { kind: "imported" };
if (errorMessage) {
return { kind: "error", message: errorMessage, onRetry: runImport };
}
if (auditError) {
return { kind: "error", message: auditError, onRetry: runImport };
}
return { kind: "ready", label: "Import", onClick: runImport };
})();

return (
<ImportRow
icon={<LuTerminal className="size-3.5" strokeWidth={2} />}
primary={preset.name}
secondary={preset.description ?? preset.commands[0]}
action={action}
/>
);
}
Loading
Loading