feat(stacks): guided first stack import flow (#1285)

* feat(stacks): add guided first stack import flow

Add an Import mode to the Create Stack dialog and a zero-stacks empty
state so a new user who already has compose files on disk can land their
first stack without reading the docs first.

A read-only scan of the compose directory (GET /api/stacks/import/scan)
lists the compose files it finds with a dry preview of each file's
services, ports, volumes, and env files. Each result is labelled by
placement: already a stack, loose at the root of the compose directory,
or one folder too deep, with the exact path to move misplaced files to.
The scan never writes, moves, or changes any files.

Manual stack creation (Empty, From Git, From Docker Run) is unchanged.

* fix(stacks): read import-scan candidates via a single file handle

Open the compose file once and stat plus read on the same descriptor so
the size check and the read observe the same inode, instead of resolving
the path twice (stat then readFile), which is a time-of-check/time-of-use
race. Mirrors the existing handle-based readers in FileSystemService.

* fix(stacks): confine import scan to the compose dir and refine the empty state

Harden the read-only import scan:
- Resolve symlinks and confirm the real target stays inside the compose
  directory before reading a candidate, and reject non-regular files, so a
  symlinked compose file or parent cannot expose a file outside the compose
  directory through the preview (matches resolveSafeStackPath).
- Read at most the stat-reported size (bounded by the 1 MiB cap) from the open
  handle, so a file that grows after the size check cannot exceed the cap.
- Log when the compose directory or a subdirectory cannot be read, so an access
  failure is not silently reported as "no compose files found".

Only show the first-run "No stacks yet" prompt when no filter chip is active, so
a filter that matches nothing is not mistaken for an empty fleet.
This commit is contained in:
Anso
2026-06-02 16:10:05 -04:00
committed by GitHub
parent c82a39c65a
commit 06b25262cc
11 changed files with 963 additions and 12 deletions
+14 -3
View File
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Plus } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
import { NotificationPanel } from './NotificationPanel';
import { TopBar } from './TopBar';
import { ViewRouter } from './EditorLayout/ViewRouter';
import { CreateStackDialog } from './EditorLayout/CreateStackDialog';
import { CreateStackDialog, type CreateMode } from './EditorLayout/CreateStackDialog';
import { EditorView } from './EditorLayout/EditorView';
import { ShellOverlays } from './EditorLayout/ShellOverlays';
import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState';
@@ -110,6 +110,14 @@ export default function EditorLayout() {
createDialogOpen, setCreateDialogOpen,
} = overlayState;
// Which mode the create dialog opens on. The toolbar Create button opens on
// 'empty'; the zero-stacks empty state opens on 'import'.
const [createDialogInitialMode, setCreateDialogInitialMode] = useState<CreateMode>('empty');
const openCreateDialog = useCallback((mode: CreateMode) => {
setCreateDialogInitialMode(mode);
setCreateDialogOpen(true);
}, [setCreateDialogOpen]);
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
// Use a ref to break the circular dependency:
@@ -311,7 +319,7 @@ export default function EditorLayout() {
<Button
variant="outline"
className="rounded-lg w-full"
onClick={() => setCreateDialogOpen(true)}
onClick={() => openCreateDialog('empty')}
>
<Plus className="w-4 h-4 mr-2" />
Create Stack
@@ -319,6 +327,7 @@ export default function EditorLayout() {
<CreateStackDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
initialMode={createDialogInitialMode}
onStackCreated={async (sName, sourceNodeId) => {
await refreshStacks();
// loadFile keeps its own unsaved-changes overlay (intentional safety,
@@ -385,6 +394,8 @@ export default function EditorLayout() {
const node = nodes.find(n => n.id === nodeId);
if (node) void stackActions.loadFileOnNode(node, file);
},
filterChip,
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
}}
activitySummary={activitySummary}
onActivityAction={handleActivityAction}
@@ -1,5 +1,5 @@
import { useRef, useState, type FormEvent, type KeyboardEvent } from 'react';
import { Plus, GitBranch, FileCode2, Loader2, type LucideIcon } from 'lucide-react';
import { Plus, GitBranch, FileCode2, FolderSearch, Loader2, type LucideIcon } from 'lucide-react';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/modal';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
@@ -7,6 +7,7 @@ import { Label } from '../ui/label';
import { ScrollArea } from '../ui/scroll-area';
import { Checkbox } from '../ui/checkbox';
import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields';
import { ImportStackPanel } from './ImportStackPanel';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
@@ -20,11 +21,15 @@ export interface CreateStackDialogProps {
// so a mid-flight node switch does not land the user on a 404.
onStackCreated: (stackName: string, sourceNodeId: number | null | undefined) => void | Promise<void>;
onStacksChanged: () => void | Promise<void>;
// Mode the dialog opens on. The empty-state entry opens directly on 'import';
// the toolbar Create button opens on 'empty'.
initialMode?: CreateMode;
}
type CreateMode = 'empty' | 'git' | 'docker-run';
export type CreateMode = 'import' | 'empty' | 'git' | 'docker-run';
const MODES: ReadonlyArray<{ id: CreateMode; label: string; icon: LucideIcon }> = [
{ id: 'import', label: 'Import', icon: FolderSearch },
{ id: 'empty', label: 'Empty', icon: Plus },
{ id: 'git', label: 'From Git', icon: GitBranch },
{ id: 'docker-run', label: 'From Docker Run', icon: FileCode2 },
@@ -33,9 +38,18 @@ const MODES: ReadonlyArray<{ id: CreateMode; label: string; icon: LucideIcon }>
const tabId = (m: CreateMode) => `create-stack-tab-${m}`;
const panelId = (m: CreateMode) => `create-stack-panel-${m}`;
export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacksChanged }: CreateStackDialogProps) {
export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacksChanged, initialMode = 'empty' }: CreateStackDialogProps) {
const { activeNode } = useNodes();
const [createMode, setCreateMode] = useState<CreateMode>('empty');
const [createMode, setCreateMode] = useState<CreateMode>(initialMode);
// Reset to the requested starting mode each time the dialog opens (empty for
// the toolbar button, import for the empty-state entry). Tracked during render
// via a previous-open sentinel rather than an effect, the pattern React
// recommends for resetting state in response to a prop change.
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) setCreateMode(initialMode);
}
const [newStackName, setNewStackName] = useState('');
// Synchronous guard. The disabled-button + setState pair can race a rapid
// second click that lands before React has committed the disabled state,
@@ -308,7 +322,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
onOpenChange={(o) => {
onOpenChange(o);
if (!o) {
setCreateMode('empty');
setCreateMode(initialMode);
resetCreateFromGitForm();
resetCreateFromDockerRunForm();
// Intentionally NOT resetting creatingEmptyRef / creatingEmpty
@@ -322,10 +336,19 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
<ModalHeader
kicker="STACKS · NEW"
title="New stack"
description="Create a new stack: empty, cloned from a Git repository, or converted from a docker run command."
description="Import a compose file you already have, or create one: empty, cloned from a Git repository, or converted from a docker run command."
/>
<ModeRail mode={createMode} onModeChange={setCreateMode} disabled={busy} />
{createMode === 'import' && (
<div role="tabpanel" id={panelId('import')} aria-labelledby={tabId('import')}>
<ImportStackPanel
onClose={() => onOpenChange(false)}
onOpenStack={(name) => { void onStackCreated(name, activeNode?.id); }}
/>
</div>
)}
{createMode === 'empty' && (
<div role="tabpanel" id={panelId('empty')} aria-labelledby={tabId('empty')}>
<form onSubmit={handleEmptyFormSubmit}>
@@ -559,7 +582,7 @@ function ModeRail({
<div
role="tablist"
aria-label="Stack source"
className="grid grid-cols-3 border-b border-card-border/60"
className="grid grid-cols-4 border-b border-card-border/60"
onKeyDown={handleKeyDown}
>
{MODES.map((m, i) => {
@@ -0,0 +1,287 @@
import { useCallback, useEffect, useState } from 'react';
import {
FolderSearch,
Loader2,
RefreshCw,
ChevronDown,
ChevronRight,
ArrowUpRight,
AlertTriangle,
CheckCircle2,
} from 'lucide-react';
import { ModalBody, ModalFooter } from '../ui/modal';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
interface ServicePreview {
name: string;
image?: string;
ports: string[];
volumes: string[];
envFiles: string[];
}
interface ImportCandidate {
name: string;
composeFile: string;
location: string;
status: 'listed' | 'loose-root' | 'nested';
services: ServicePreview[];
warnings: string[];
parseError?: string;
}
interface ImportScanResponse {
composeDir: string;
candidates: ImportCandidate[];
}
export interface ImportStackPanelProps {
onClose: () => void;
// Navigate to an already-listed stack (it is already in the sidebar).
onOpenStack: (name: string) => void;
}
// Join a host compose-dir path with extra segments for display only. The dir is
// the host path as the node reports it, so a plain "/" join reads correctly.
function joinPath(base: string, ...segments: string[]): string {
const trimmed = base.replace(/[/\\]+$/, '');
return [trimmed, ...segments].join('/');
}
export function ImportStackPanel({ onClose, onOpenStack }: ImportStackPanelProps) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<ImportScanResponse | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const scan = useCallback(async () => {
setLoading(true);
try {
const response = await apiFetch('/stacks/import/scan');
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error((body as { error?: string })?.error || 'Failed to scan the compose directory.');
}
setData((await response.json()) as ImportScanResponse);
} catch (error) {
console.error('Failed to scan compose directory:', error);
toast.error((error as Error).message || 'Failed to scan the compose directory.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void scan();
}, [scan]);
const toggle = (location: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(location)) next.delete(location);
else next.add(location);
return next;
});
};
const composeDir = data?.composeDir ?? '';
const candidates = data?.candidates ?? [];
return (
<>
<ScrollArea block className="max-h-[60vh]">
<ModalBody>
<div className="rounded-md border border-card-border border-t-card-border-top bg-card/60 px-3 py-2.5 shadow-card-bevel">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Sencho looks for stacks in
</div>
<div className="mt-1 break-all font-mono text-xs text-stat-value">{composeDir || '—'}</div>
<p className="mt-2 text-xs leading-relaxed text-stat-subtitle">
Each stack lives in its own subfolder here. Keep the host mount path the same as the
path inside the container so relative volumes resolve (the 1:1 path rule).{' '}
<a
href="https://docs.sencho.io/getting-started/configuration"
target="_blank"
rel="noreferrer"
className="text-brand hover:underline"
>
Learn more
</a>
</p>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-stat-subtitle">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Scanning
</div>
) : candidates.length === 0 ? (
<div className="py-10 text-center">
<FolderSearch className="mx-auto h-6 w-6 text-stat-icon" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">No compose files found.</p>
<p className="mx-auto mt-1 max-w-sm text-xs leading-relaxed text-stat-subtitle">
Put each stack in its own subfolder inside the compose directory, then rescan. Or
pick another source above to create one from scratch.
</p>
</div>
) : (
<div className="space-y-2">
{candidates.map((c) => (
<CandidateCard
key={c.location}
candidate={c}
composeDir={composeDir}
expanded={expanded.has(c.location)}
onToggle={() => toggle(c.location)}
onOpenStack={(name) => {
onClose();
onOpenStack(name);
}}
/>
))}
</div>
)}
</ModalBody>
</ScrollArea>
<ModalFooter
hint="READ ONLY · NO FILES CHANGED"
secondary={
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
}
primary={
<Button onClick={() => void scan()} disabled={loading}>
{loading ? (
<><Loader2 className="mr-1.5 h-4 w-4 animate-spin" strokeWidth={1.5} />Scanning</>
) : (
<><RefreshCw className="mr-1.5 h-4 w-4" strokeWidth={1.5} />Rescan</>
)}
</Button>
}
/>
</>
);
}
function CandidateCard({
candidate,
composeDir,
expanded,
onToggle,
onOpenStack,
}: {
candidate: ImportCandidate;
composeDir: string;
expanded: boolean;
onToggle: () => void;
onOpenStack: (name: string) => void;
}) {
const { name, composeFile, location, status, services, warnings, parseError } = candidate;
const displayName = name || '<name>';
const target = joinPath(composeDir, displayName, composeFile);
return (
<div className="rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left"
aria-expanded={expanded}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-stat-icon" strokeWidth={1.5} />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-stat-icon" strokeWidth={1.5} />
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-sm text-stat-value">{displayName}</span>
<span className="block truncate font-mono text-[10px] text-stat-subtitle">{location}</span>
</span>
<StatusBadge status={status} />
</button>
{expanded && (
<div className="border-t border-card-border/60 px-3 py-2.5 space-y-2.5">
{status === 'listed' ? (
<button
type="button"
onClick={() => onOpenStack(name)}
className="inline-flex items-center gap-1.5 text-xs text-brand hover:underline"
>
Open in sidebar
<ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
</button>
) : (
<div className="flex gap-2 rounded-md border border-warning/30 bg-warning/5 px-2.5 py-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" strokeWidth={1.5} />
<div className="text-xs leading-relaxed text-stat-subtitle">
Not in its own subfolder, so it will not show as a stack. Move it to{' '}
<span className="break-all font-mono text-stat-value">{target}</span>, then rescan.
</div>
</div>
)}
{parseError ? (
<p className="font-mono text-[11px] text-destructive">{parseError}</p>
) : (
<ServiceList services={services} />
)}
{warnings.map((w) => (
<p key={w} className="text-[11px] leading-relaxed text-warning">{w}</p>
))}
</div>
)}
</div>
);
}
function StatusBadge({ status }: { status: ImportCandidate['status'] }) {
if (status === 'listed') {
return (
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-[10px] uppercase tracking-[0.12em] text-success">
<CheckCircle2 className="h-3 w-3" strokeWidth={1.5} />
In sidebar
</span>
);
}
return (
<span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-warning">
Needs move
</span>
);
}
function ServiceList({ services }: { services: ServicePreview[] }) {
if (services.length === 0) {
return <p className="font-mono text-[11px] text-stat-subtitle">No services to preview.</p>;
}
return (
<div className="space-y-2">
{services.map((svc) => (
<div key={svc.name} className="font-mono text-[11px] leading-relaxed">
<div className="text-stat-value">
{svc.name}
{svc.image ? <span className="text-stat-subtitle"> · {svc.image}</span> : null}
</div>
<MetaRow label="ports" values={svc.ports} />
<MetaRow label="volumes" values={svc.volumes} />
<MetaRow label="env" values={svc.envFiles} />
</div>
))}
</div>
);
}
function MetaRow({ label, values }: { label: string; values: string[] }) {
if (values.length === 0) return null;
return (
<div className="flex gap-2 text-stat-subtitle">
<span className="w-12 shrink-0 text-stat-icon">{label}</span>
<span className="min-w-0 flex-1 break-all">{values.join(' · ')}</span>
</div>
);
}
@@ -0,0 +1,32 @@
import { FolderSearch, Plus, Layers } from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface EmptyStackStateProps {
// Open the create dialog on a given starting mode. Provided only when the
// user has permission to create stacks; otherwise the buttons are hidden.
onOpenCreate?: (mode: 'import' | 'empty') => void;
}
export function EmptyStackState({ onOpenCreate }: EmptyStackStateProps) {
return (
<div className="px-3 py-8 text-center">
<Layers className="mx-auto h-6 w-6 text-stat-icon" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">No stacks yet</p>
<p className="mx-auto mt-1 max-w-[200px] text-xs leading-relaxed text-stat-subtitle">
Import compose files you already have, or create one from scratch.
</p>
{onOpenCreate && (
<div className="mt-4 flex flex-col gap-2">
<Button size="sm" className="w-full" onClick={() => onOpenCreate('import')}>
<FolderSearch className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
Import existing
</Button>
<Button size="sm" variant="outline" className="w-full" onClick={() => onOpenCreate('empty')}>
<Plus className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
New stack
</Button>
</div>
)}
</div>
);
}
+17 -1
View File
@@ -10,7 +10,8 @@ import type { StackRowStatus } from './stack-status-utils';
import { StackGroup } from './StackGroup';
import { StackContextMenu } from './StackContextMenu';
import { StackKebabMenu } from './StackKebabMenu';
import type { StackMenuCtx } from './sidebar-types';
import { EmptyStackState } from './EmptyStackState';
import type { StackMenuCtx, FilterChip } from './sidebar-types';
interface RemoteNodeResult {
nodeId: number;
@@ -45,6 +46,13 @@ export interface StackListProps {
remoteLoading: boolean;
remoteFailedNodes: RemoteSearchFailure[];
onSelectRemoteFile: (nodeId: number, file: string) => void;
// Active filter chip. The first-run empty state only renders when no chip is
// applied ('all'), so a filter that matches nothing is not mistaken for "no
// stacks yet".
filterChip: FilterChip;
// Open the create dialog on a starting mode. Present only when the user can
// create stacks; drives the zero-stacks empty state.
onOpenCreate?: (mode: 'import' | 'empty') => void;
}
interface BuiltGroup {
@@ -115,6 +123,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
isBusy, getDisplayName, onSelectFile, buildMenuCtx,
bulkMode, selectedFiles, onToggleSelect,
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
filterChip, onOpenCreate,
} = props;
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
@@ -136,6 +145,13 @@ export function StackList(props: StackListProps & StackListBulkProps) {
);
}
// First-run prompt only when the node has no stacks at all: no search text and
// no active filter chip, so a filter that happens to match nothing does not
// masquerade as an empty fleet.
if (files.length === 0 && !searchQuery.trim() && filterChip === 'all') {
return <EmptyStackState onOpenCreate={onOpenCreate} />;
}
return (
<CommandList className="max-h-none overflow-visible">
{groups.map(g => (