Refactor: improve code quality and worker flow

This commit is contained in:
MangoPig
2026-06-26 18:32:49 +01:00
parent adcc9afe05
commit 7e62ff6d9a
16 changed files with 1444 additions and 358 deletions
@@ -103,6 +103,9 @@ const buildProjectTree = (
items: readonly ProjectItem[],
folders: readonly PersistedProjectFolderRecord[] = [],
): ProjectTreeNode[] => [
// The selector still presents scaffold project leaves beside persisted folders.
// Keep that mixed root shape explicit here so the drag/drop logic can account
// for folder-only ordering when we persist sibling positions.
...items.map((item) => ({
kind: "project" as const,
item,
@@ -110,6 +113,11 @@ const buildProjectTree = (
...buildPersistedFolderNodes(folders),
];
const countProjectFolderSiblingsBeforeIndex = (
siblings: readonly ProjectTreeNode[],
index: number,
): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
@@ -596,10 +604,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
? persistedParentLocation.node.children
: []
: previewNodes;
// The preview tree includes project leaves and folders, but the backend only
// stores sibling order for folders. Persist a folder-only index so the server
// can reapply the same position against the authoritative ordered tree.
const targetIndex = previewLocation
? previewSiblings
.slice(0, previewLocation.index)
.filter((node) => node.kind === "folder").length
? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
: 0;
if (
@@ -795,12 +804,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const movePersistedFolder = async (
folderPath: string,
parentFolderPath: string | null,
folderNodeId: string,
parentNodeId: string | null,
folderStableId: string,
parentStableId: string | null,
targetIndex: number,
): Promise<void> => {
const projectId = selectedProject().id;
if (!folderPath || !folderNodeId || !isUuidString(projectId)) {
if (!folderPath || !folderStableId || !isUuidString(projectId)) {
return;
}
@@ -813,9 +822,9 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
},
body: JSON.stringify({
folderId: folderPath,
folderNodeId,
folderNodeId: folderStableId,
parentFolderId: parentFolderPath,
parentNodeId,
parentNodeId: parentStableId,
targetIndex,
}),
});
@@ -93,6 +93,11 @@ const buildPersistedWorkspaceFolderNodes = (
const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
const countWorkspaceFolderSiblingsBeforeIndex = (
siblings: readonly WorkspaceTreeNode[],
index: number,
): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
const workspaceTreeAdapter: NavTreeAdapter<WorkspaceTreeNode> = {
getNodeId: getWorkspaceTreeNodeId,
isBranchNode: (node) => node.kind === "folder",
@@ -548,10 +553,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
? persistedParentLocation.node.children ?? []
: []
: previewNodes;
// The tree preview can include static/workspace items, but persisted ordering
// only applies to folder siblings. Convert the preview position into a folder-
// only index before sending it to the backend move endpoint.
const targetIndex = previewLocation
? previewSiblings
.slice(0, previewLocation.index)
.filter((node) => node.kind === "folder").length
? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
: 0;
if (
@@ -716,12 +722,12 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const movePersistedFolder = async (
folderPath: string,
parentFolderPath: string | null,
folderNodeId: string,
parentNodeId: string | null,
folderStableId: string,
parentStableId: string | null,
targetIndex: number,
): Promise<void> => {
const projectId = activeProject()?.id ?? "";
if (!folderPath || !folderNodeId || !projectId || !isUuidString(projectId)) {
if (!folderPath || !folderStableId || !projectId || !isUuidString(projectId)) {
return;
}
@@ -734,9 +740,9 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
},
body: JSON.stringify({
folderId: folderPath,
folderNodeId,
folderNodeId: folderStableId,
parentFolderId: parentFolderPath,
parentNodeId,
parentNodeId: parentStableId,
targetIndex,
}),
});
@@ -40,6 +40,8 @@ type AppShellInstallation = {
protocol: string;
host: string;
isBootstrapped: boolean;
materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
materializationError?: string;
};
type AppShellAdmin = {
@@ -101,8 +103,29 @@ type AppShellPayload = {
workspaces: AppShellWorkspace[];
};
const normalizeInstallation = (
installation: AppShellInstallation | null | undefined,
): AppShellInstallation | undefined => {
if (!installation) {
return undefined;
}
const materializationStatus = installation.materializationStatus?.trim()
? installation.materializationStatus
: installation.isBootstrapped
? "succeeded"
: "not_started";
const materializationError = installation.materializationError?.trim() || undefined;
return {
...installation,
materializationStatus,
materializationError,
};
};
const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: payload?.installation,
installation: normalizeInstallation(payload?.installation),
admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [],
@@ -92,6 +92,21 @@
flex-wrap: wrap;
}
.heroStatus {
display: grid;
gap: var(--space-2);
justify-items: start;
}
.heroStatusMessage {
max-width: 64ch;
color: var(--color-text-muted);
}
.heroStatusMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.title {
@include text-display;
font-family: var(--font-family-display);
@@ -194,6 +209,19 @@
background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent);
}
.statusBadge[data-status="pending"],
.statusBadge[data-status="running"] {
color: var(--bootstrap-accent);
border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface-secondary));
}
.statusBadge[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent);
}
.statusBadge[data-status="error"] {
color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
@@ -1,6 +1,6 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, Show, createEffect, createMemo, createSignal, type JSX } from "solid-js";
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api";
@@ -44,6 +44,8 @@ type StructureForm = {
projectName: string;
};
type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{
id: "instance",
@@ -104,6 +106,8 @@ const initialSubmissionState = (): BootstrapSubmissionState => ({
error: "",
});
const materializationPollIntervalMs = 2000;
const readResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text();
@@ -185,6 +189,51 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false);
const [isWizardOpen, setIsWizardOpen] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
const installation = createMemo(() => appShellData.installation());
const materializationState = createMemo<MaterializationState>(() => {
const status = installation()?.materializationStatus;
switch (status) {
case "pending":
case "running":
case "failed":
case "succeeded":
case "not_started":
return status;
default:
return installation()?.isBootstrapped ? "succeeded" : "not_started";
}
});
const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false);
const isMaterializationInFlight = createMemo(
() => materializationState() === "pending" || materializationState() === "running",
);
const hasMaterializationFailed = createMemo(() => materializationState() === "failed");
const materializationStatusLabel = createMemo(() => {
switch (materializationState()) {
case "pending":
return "Materialization queued";
case "running":
return "Materialization running";
case "failed":
return "Materialization failed";
case "succeeded":
return "Ready";
default:
return "Not started";
}
});
const materializationMessage = createMemo(() => {
if (isMaterializationInFlight()) {
return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index.";
}
if (hasMaterializationFailed()) {
return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly.";
}
return "";
});
createEffect(() => {
if (modeForm.mode === "personal") {
@@ -213,19 +262,51 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
return;
}
const installationAccessor = appShellData.installation;
const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined;
const isPersistedBootstrap = installation?.isBootstrapped ?? false;
if (!isPersistedBootstrap) {
if (!isBootstrapPersisted()) {
resetWizardState();
}
setIsBootstrapComplete(isPersistedBootstrap);
setIsWizardOpen(!isPersistedBootstrap);
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted());
setIsBootstrapStateResolved(true);
});
createEffect(() => {
if (!isBootstrapPersisted() || !isMaterializationInFlight()) {
return;
}
let cancelled = false;
let timeoutId: number | undefined;
const scheduleReload = (): void => {
timeoutId = window.setTimeout(async () => {
if (cancelled) {
return;
}
// The final bootstrap step only persists relational state. Poll while the
// worker is materializing the POSIX skeleton so the page can transition from
// queued/running to ready/failed without a manual refresh.
await appShellData.reload();
if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) {
scheduleReload();
}
}, materializationPollIntervalMs);
};
scheduleReload();
onCleanup(() => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
});
const sidebarToggleLabel = (): string =>
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
@@ -240,7 +321,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
const canDismissWizard = (): boolean => isBootstrapComplete();
const canDismissWizard = (): boolean => isBootstrapPersisted();
const resetWizardState = (): void => {
setInstanceForm({ ...defaultInstanceForm });
@@ -313,12 +394,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
if (isLastStep()) {
await appShellData.reload();
const installationAccessor = appShellData.installation;
const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined;
const isPersistedBootstrap = installation?.isBootstrapped ?? false;
setIsBootstrapComplete(isPersistedBootstrap);
setIsWizardOpen(!isPersistedBootstrap);
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted());
setIsBootstrapStateResolved(true);
return;
}
@@ -383,20 +461,33 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
</div>
<section class={styles.hero} data-slot="workspace-home-hero">
<h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}>
<h1 class={styles.title}>{isBootstrapPersisted() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
<Show when={isBootstrapStateResolved() && isBootstrapPersisted() && materializationState() !== "succeeded"}>
<div class={styles.heroStatus}>
<div class={styles.statusBadge} data-status={materializationState()}>
{materializationStatusLabel()}
</div>
<Show when={materializationMessage()}>
<p class={styles.heroStatusMessage} data-status={materializationState()}>
{materializationMessage()}
</p>
</Show>
</div>
</Show>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}>
<button
type="button"
class={styles.primaryButton}
disabled={isBootstrapPersisted()}
onClick={(): void => {
setIsWizardOpen(true);
}}
>
Open bootstrap wizard
</button>
</div>
</Show>
{isBootstrapPersisted() ? "Bootstrap saved" : "Open bootstrap wizard"}
</button>
</div>
</Show>
</section>
</main>
@@ -456,12 +547,14 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<div class={styles.sectionHeader}>
<div>
<span class={styles.wizardStepEyebrow}>{`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`}</span>
<h3 class={styles.sectionTitle}>{currentStep().title}</h3>
</div>
<div class={styles.statusBadge} data-status={currentStepState().status}>{statusLabel(currentStepState())}</div>
</div>
<div>
<span class={styles.wizardStepEyebrow}>{`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`}</span>
<h3 class={styles.sectionTitle}>{currentStep().title}</h3>
</div>
<div class={styles.statusBadge} data-status={currentStepState().status}>
{statusLabel(currentStepState())}
</div>
</div>
<form class={styles.form} onSubmit={handleCurrentStepSubmit}>
<Show when={currentStep().id === "instance"}>