mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 07:13:05 +00:00
feat(ui): make the core stack flow usable on mobile (#1327)
* feat(ui): make the core stack flow usable on mobile Below the md breakpoint the app collapses to a single full-width column: the stack list is full-screen, tapping a stack opens a full-screen detail with a Health / Logs / Compose segmented control (Logs first) and a back button, and a bottom tab bar switches Stacks, Fleet, Schedules, and Settings. Compose is read-only on a phone with a prompt to edit on desktop. Desktop (md and up) is unchanged: the mobile shell is gated behind a useIsMobile hook plus max-md/md variants, and the stack-detail blocks are shared with the desktop two-pane view so it renders identically. Also generalizes the unsaved-changes guard so leaving a dirty editor (back, tab bar, hamburger) prompts before discarding; adds 44px touch targets on list rows, filter chips, and actions; makes log and shell modals full-screen on mobile; and offsets toasts and the deploy pill above the bottom tab bar. * fix(ui): keep mobile nav in sync when opening views from outside the bottom bar On a phone the sidebar activity actions, the node switcher's Manage Nodes, the profile Settings entry, and the dashboard configuration links set the active view without flipping the mobile surface to content, so the user stayed on the stack list and never saw the destination. Route these through the mobile-aware navigation and settings helpers (a no-op on desktop).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Plus, Loader2, ChevronLeft } from 'lucide-react';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
import { NotificationPanel } from './NotificationPanel';
|
||||
import { TopBar } from './TopBar';
|
||||
@@ -38,6 +38,10 @@ import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionSt
|
||||
import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { MobileTabBar } from './MobileTabBar';
|
||||
import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surface';
|
||||
import type { SectionId } from './settings/types';
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin, can } = useAuth();
|
||||
@@ -198,26 +202,143 @@ export default function EditorLayout() {
|
||||
nextAutoUpdateRunAt,
|
||||
});
|
||||
|
||||
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
|
||||
const stackName = selectedFile || '';
|
||||
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// ---- Mobile shell (below md) ---------------------------------------------
|
||||
// Desktop renders the persistent sidebar + workspace untouched. On a phone we
|
||||
// show exactly one surface at a time: the stack list, a top-level view, or a
|
||||
// full-screen stack detail. `mobileView` is explicit state, decoupled from
|
||||
// `activeView`, so 'dashboard' still maps to HomeDashboard everywhere.
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileView, setMobileView] = useState<MobileView>('list');
|
||||
// Optimistically flip to the detail surface the instant a row is tapped,
|
||||
// before loadFile's fetch resolves selectedFile; cleared once it settles.
|
||||
const [pendingDetailStack, setPendingDetailStack] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!isFileLoading && pendingDetailStack) setPendingDetailStack(null);
|
||||
}, [isFileLoading, pendingDetailStack]);
|
||||
|
||||
const { surface: mobileSurface, detailReady, detailOpen } = deriveMobileSurface({
|
||||
activeView,
|
||||
selectedFile,
|
||||
mobileView,
|
||||
pendingDetailStack,
|
||||
});
|
||||
|
||||
// A phone shows one surface at a time, so every mobile navigation tears down
|
||||
// the current detail and switches surfaces, guarding a dirty editor first.
|
||||
// `then` runs the destination-specific work (navigate to a view, open
|
||||
// settings) after the surface flips.
|
||||
const leaveToMobileSurface = (target: MobileView, then?: () => void) => {
|
||||
stackActions.attemptLeaveEditor(() => {
|
||||
stackActions.resetEditorState();
|
||||
setPendingDetailStack(null);
|
||||
setMobileView(target);
|
||||
then?.();
|
||||
});
|
||||
};
|
||||
|
||||
const goToMobileList = () => leaveToMobileSurface('list');
|
||||
const navigateMobileAware = (view: string) => leaveToMobileSurface('content', () => handleNavigate(view));
|
||||
const openSettingsMobileAware = (section?: SectionId) =>
|
||||
leaveToMobileSurface('content', () => handleOpenSettings(section));
|
||||
|
||||
// Settings navigation from outside the bottom bar (profile menu, node
|
||||
// switcher, dashboard config links). On mobile it flips to the content
|
||||
// surface so the section is actually shown instead of leaving the user on
|
||||
// the stack list; on desktop it is the plain open.
|
||||
const openSettings = (section?: SectionId) =>
|
||||
(isMobile ? openSettingsMobileAware(section) : handleOpenSettings(section));
|
||||
|
||||
// Tapping a stack row on mobile flips to the detail surface immediately.
|
||||
const handleSelectStack = (file: string) => {
|
||||
if (isMobile) setPendingDetailStack(file);
|
||||
void stackActions.loadFile(file);
|
||||
};
|
||||
|
||||
// Hamburger / command-palette navigation is mobile-aware so it collapses the
|
||||
// current surface and honors the unsaved-changes guard; desktop is untouched.
|
||||
const navHandler = isMobile ? navigateMobileAware : handleNavigate;
|
||||
|
||||
// Sidebar activity actions navigate to top-level views. On mobile they must
|
||||
// flip the surface to content (otherwise the user stays on the stack list);
|
||||
// on desktop they set the view directly as before.
|
||||
const handleActivityAction = useCallback((action: SidebarActivityAction) => {
|
||||
switch (action.kind) {
|
||||
case 'open-stack-notification':
|
||||
stackActions.navigateToNotification(action.summary.notif);
|
||||
return;
|
||||
case 'open-auto-updates':
|
||||
setActiveView('auto-updates');
|
||||
if (isMobile) navigateMobileAware('auto-updates');
|
||||
else setActiveView('auto-updates');
|
||||
return;
|
||||
case 'open-activity':
|
||||
setActiveView('global-observability');
|
||||
if (isMobile) navigateMobileAware('global-observability');
|
||||
else setActiveView('global-observability');
|
||||
return;
|
||||
case 'noop':
|
||||
return;
|
||||
}
|
||||
}, [stackActions, setActiveView]);
|
||||
}, [stackActions, setActiveView, isMobile, navigateMobileAware]);
|
||||
|
||||
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
|
||||
const stackName = selectedFile || '';
|
||||
|
||||
const { isDarkMode } = useTheme();
|
||||
const renderEditor = () => (
|
||||
<EditorView
|
||||
stackName={stackName}
|
||||
isDarkMode={isDarkMode}
|
||||
containers={containers}
|
||||
containerStats={containerStats}
|
||||
containerStatsError={containerStatsError}
|
||||
content={content}
|
||||
envContent={envContent}
|
||||
envExists={envExists}
|
||||
envFiles={envFiles}
|
||||
selectedEnvFile={selectedEnvFile}
|
||||
isFileLoading={isFileLoading}
|
||||
backupInfo={backupInfo}
|
||||
gitSourcePendingMap={gitSourcePendingMap}
|
||||
notifications={notifications}
|
||||
activeTab={activeTab}
|
||||
isEditing={isEditing}
|
||||
editingCompose={editingCompose}
|
||||
logsMode={logsMode}
|
||||
copiedDigest={copiedDigest}
|
||||
loadingAction={loadingAction}
|
||||
stackMisconfigScanning={stackMisconfigScanning}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
activeNode={activeNode}
|
||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
||||
deployStack={stackActions.deployStack}
|
||||
restartStack={stackActions.restartStack}
|
||||
stopStack={stackActions.stopStack}
|
||||
updateStack={stackActions.updateStack}
|
||||
rollbackStack={stackActions.rollbackStack}
|
||||
scanStackConfig={stackActions.scanStackConfig}
|
||||
enterEditMode={stackActions.enterEditMode}
|
||||
requestSave={stackActions.requestSave}
|
||||
requestSaveAndDeploy={stackActions.requestSaveAndDeploy}
|
||||
discardChanges={stackActions.discardChanges}
|
||||
setContent={setContent}
|
||||
setEnvContent={setEnvContent}
|
||||
changeEnvFile={stackActions.changeEnvFile}
|
||||
openLogViewer={stackActions.openLogViewer}
|
||||
openBashModal={stackActions.openBashModal}
|
||||
serviceAction={stackActions.serviceAction}
|
||||
setActiveTab={setActiveTab}
|
||||
setLogsMode={setLogsMode}
|
||||
setEditingCompose={setEditingCompose}
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
setCopiedDigest={setCopiedDigest}
|
||||
requestDeleteStack={stackActions.requestDeleteStack}
|
||||
onMobileBack={goToMobileList}
|
||||
/>
|
||||
);
|
||||
|
||||
// Track the last "committed" node id so the node-switch dirty guard can
|
||||
// detect an actual switch (vs the initial mount or an internal revert).
|
||||
@@ -341,71 +462,74 @@ export default function EditorLayout() {
|
||||
|
||||
return (
|
||||
<GlobalCommandPaletteProvider>
|
||||
<div className="flex h-screen w-screen overflow-hidden app-canvas text-foreground">
|
||||
<GlobalCommandPalette
|
||||
navItems={navItems}
|
||||
onNavigate={handleNavigate}
|
||||
onSelectStack={stackActions.loadFileOnNode}
|
||||
/>
|
||||
{/* Left Sidebar (Stacks) */}
|
||||
<StackSidebar
|
||||
isDarkMode={isDarkMode}
|
||||
nodeSwitcherSlot={
|
||||
<NodeSwitcher
|
||||
onManageNodes={() => handleOpenSettings('nodes')}
|
||||
/>
|
||||
}
|
||||
createStackSlot={createStackSlot}
|
||||
onScan={handleScanStacks}
|
||||
isScanning={isScanning}
|
||||
canCreate={can('stack:create')}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filterChip={filterChip}
|
||||
filterCounts={filterCounts}
|
||||
onFilterChipChange={setFilterChip}
|
||||
list={{
|
||||
files: chipFilteredFiles,
|
||||
isLoading,
|
||||
selectedFile,
|
||||
searchQuery,
|
||||
stackLabelMap,
|
||||
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
||||
stackUpdates,
|
||||
gitSourcePendingMap,
|
||||
pinnedFiles: pinned,
|
||||
isCollapsed,
|
||||
toggleCollapse,
|
||||
isBusy: isStackBusy,
|
||||
getDisplayName: stackActions.getDisplayName,
|
||||
onSelectFile: stackActions.loadFile,
|
||||
buildMenuCtx,
|
||||
remoteResults,
|
||||
remoteLoading: remoteSearchLoading,
|
||||
remoteFailedNodes: remoteSearchFailedNodes,
|
||||
onSelectRemoteFile: (nodeId, file) => {
|
||||
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}
|
||||
bulkMode={bulkMode}
|
||||
selectedFiles={selectedFiles}
|
||||
onToggleBulkMode={toggleBulkMode}
|
||||
onToggleSelect={toggleSelect}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkAction={handleBulkAction}
|
||||
/>
|
||||
{(() => {
|
||||
const commandPaletteEl = (
|
||||
<GlobalCommandPalette
|
||||
navItems={navItems}
|
||||
onNavigate={navHandler}
|
||||
onSelectStack={stackActions.loadFileOnNode}
|
||||
/>
|
||||
);
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
const sidebarEl = (
|
||||
<StackSidebar
|
||||
isDarkMode={isDarkMode}
|
||||
nodeSwitcherSlot={
|
||||
<NodeSwitcher
|
||||
onManageNodes={() => openSettings('nodes')}
|
||||
/>
|
||||
}
|
||||
createStackSlot={createStackSlot}
|
||||
onScan={handleScanStacks}
|
||||
isScanning={isScanning}
|
||||
canCreate={can('stack:create')}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filterChip={filterChip}
|
||||
filterCounts={filterCounts}
|
||||
onFilterChipChange={setFilterChip}
|
||||
list={{
|
||||
files: chipFilteredFiles,
|
||||
isLoading,
|
||||
selectedFile,
|
||||
searchQuery,
|
||||
stackLabelMap,
|
||||
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
||||
stackUpdates,
|
||||
gitSourcePendingMap,
|
||||
pinnedFiles: pinned,
|
||||
isCollapsed,
|
||||
toggleCollapse,
|
||||
isBusy: isStackBusy,
|
||||
getDisplayName: stackActions.getDisplayName,
|
||||
onSelectFile: handleSelectStack,
|
||||
buildMenuCtx,
|
||||
remoteResults,
|
||||
remoteLoading: remoteSearchLoading,
|
||||
remoteFailedNodes: remoteSearchFailedNodes,
|
||||
onSelectRemoteFile: (nodeId, file) => {
|
||||
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}
|
||||
bulkMode={bulkMode}
|
||||
selectedFiles={selectedFiles}
|
||||
onToggleBulkMode={toggleBulkMode}
|
||||
onToggleSelect={toggleSelect}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkAction={handleBulkAction}
|
||||
/>
|
||||
);
|
||||
|
||||
const topBarEl = (
|
||||
<TopBar
|
||||
activeView={activeView}
|
||||
navItems={navItems}
|
||||
onNavigate={handleNavigate}
|
||||
onNavigate={navHandler}
|
||||
mobileNavOpen={mobileNavOpen}
|
||||
onMobileNavOpenChange={setMobileNavOpen}
|
||||
search={<GlobalCommandPaletteTrigger />}
|
||||
@@ -422,13 +546,14 @@ export default function EditorLayout() {
|
||||
}
|
||||
userMenu={
|
||||
<UserProfileDropdown
|
||||
onOpenSettings={() => handleOpenSettings('account')}
|
||||
onOpenSettings={() => openSettings('account')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
{/* Main Workspace */}
|
||||
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
|
||||
const workspaceEl = (
|
||||
<div key={activeView} className="flex-1 overflow-y-auto p-6 max-md:p-4 animate-fade-up">
|
||||
<ViewRouter
|
||||
activeView={activeView}
|
||||
selectedFile={selectedFile}
|
||||
@@ -457,78 +582,101 @@ export default function EditorLayout() {
|
||||
onPrefillConsumed={handlePrefillConsumed}
|
||||
notifications={notifications}
|
||||
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
||||
onOpenSettingsSection={(section) => handleOpenSettings(section)}
|
||||
onOpenSettingsSection={(section) => openSettings(section)}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
renderEditor={() => (
|
||||
<EditorView
|
||||
stackName={stackName}
|
||||
isDarkMode={isDarkMode}
|
||||
containers={containers}
|
||||
containerStats={containerStats}
|
||||
containerStatsError={containerStatsError}
|
||||
content={content}
|
||||
envContent={envContent}
|
||||
envExists={envExists}
|
||||
envFiles={envFiles}
|
||||
selectedEnvFile={selectedEnvFile}
|
||||
isFileLoading={isFileLoading}
|
||||
backupInfo={backupInfo}
|
||||
gitSourcePendingMap={gitSourcePendingMap}
|
||||
notifications={notifications}
|
||||
activeTab={activeTab}
|
||||
isEditing={isEditing}
|
||||
editingCompose={editingCompose}
|
||||
logsMode={logsMode}
|
||||
copiedDigest={copiedDigest}
|
||||
loadingAction={loadingAction}
|
||||
stackMisconfigScanning={stackMisconfigScanning}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
activeNode={activeNode}
|
||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
||||
deployStack={stackActions.deployStack}
|
||||
restartStack={stackActions.restartStack}
|
||||
stopStack={stackActions.stopStack}
|
||||
updateStack={stackActions.updateStack}
|
||||
rollbackStack={stackActions.rollbackStack}
|
||||
scanStackConfig={stackActions.scanStackConfig}
|
||||
enterEditMode={stackActions.enterEditMode}
|
||||
requestSave={stackActions.requestSave}
|
||||
requestSaveAndDeploy={stackActions.requestSaveAndDeploy}
|
||||
discardChanges={stackActions.discardChanges}
|
||||
setContent={setContent}
|
||||
setEnvContent={setEnvContent}
|
||||
changeEnvFile={stackActions.changeEnvFile}
|
||||
openLogViewer={stackActions.openLogViewer}
|
||||
openBashModal={stackActions.openBashModal}
|
||||
serviceAction={stackActions.serviceAction}
|
||||
setActiveTab={setActiveTab}
|
||||
setLogsMode={setLogsMode}
|
||||
setEditingCompose={setEditingCompose}
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
setCopiedDigest={setCopiedDigest}
|
||||
requestDeleteStack={stackActions.requestDeleteStack}
|
||||
/>
|
||||
)}
|
||||
renderEditor={renderEditor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<ShellOverlays
|
||||
overlayState={overlayState}
|
||||
stackActions={stackActions}
|
||||
isDarkMode={isDarkMode}
|
||||
isAdmin={isAdmin}
|
||||
can={can}
|
||||
selectedFile={selectedFile}
|
||||
stackName={stackName}
|
||||
gitSourceOpen={gitSourceOpen}
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
securityHistoryOpen={securityHistoryOpen}
|
||||
setSecurityHistoryOpen={setSecurityHistoryOpen}
|
||||
/>
|
||||
</div>
|
||||
const shellOverlaysEl = (
|
||||
<ShellOverlays
|
||||
overlayState={overlayState}
|
||||
stackActions={stackActions}
|
||||
isDarkMode={isDarkMode}
|
||||
isAdmin={isAdmin}
|
||||
can={can}
|
||||
selectedFile={selectedFile}
|
||||
stackName={stackName}
|
||||
gitSourceOpen={gitSourceOpen}
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
securityHistoryOpen={securityHistoryOpen}
|
||||
setSecurityHistoryOpen={setSecurityHistoryOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden app-canvas text-foreground">
|
||||
{commandPaletteEl}
|
||||
{mobileSurface !== 'detail' && topBarEl}
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{mobileSurface === 'list' && sidebarEl}
|
||||
{mobileSurface === 'content' && workspaceEl}
|
||||
{mobileSurface === 'detail' && (
|
||||
detailReady ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderEditor()}</div>
|
||||
) : (
|
||||
<MobileDetailLoading name={pendingDetailStack ?? ''} onBack={goToMobileList} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<MobileTabBar
|
||||
navItems={navItems}
|
||||
activeView={activeView}
|
||||
mobileView={mobileView}
|
||||
detailOpen={detailOpen}
|
||||
onStacks={goToMobileList}
|
||||
onNavigate={navigateMobileAware}
|
||||
onSettings={openSettingsMobileAware}
|
||||
/>
|
||||
{shellOverlaysEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden app-canvas text-foreground">
|
||||
{commandPaletteEl}
|
||||
{/* Left Sidebar (Stacks) */}
|
||||
{sidebarEl}
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{topBarEl}
|
||||
{/* Main Workspace */}
|
||||
{workspaceEl}
|
||||
</div>
|
||||
{shellOverlaysEl}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</GlobalCommandPaletteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Optimistic stack-detail placeholder shown on mobile the instant a row is
|
||||
// tapped, until loadFile resolves and the real EditorView mounts. Keeps the tap
|
||||
// feeling immediate on slow networks.
|
||||
function MobileDetailLoading({ name, onBack }: { name: string; onBack: () => void }) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex items-center gap-1 border-b border-hairline px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back to stacks"
|
||||
className="inline-flex min-h-11 items-center gap-1 pr-3 font-mono text-xs text-brand"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
|
||||
Stacks
|
||||
</button>
|
||||
<span className="truncate font-display text-2xl italic text-stat-value">
|
||||
{name.replace(/\.(ya?ml)$/, '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-stat-subtitle">
|
||||
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user