diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 93ed3db6..3c067e1a 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -11,11 +11,15 @@ import { useFeatures } from '~/lib/hooks/useFeatures'; import { useNotifications } from '~/lib/hooks/useNotifications'; import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus'; import { useDebugStatus } from '~/lib/hooks/useDebugStatus'; -import { tabConfigurationStore, developerModeStore, setDeveloperMode } from '~/lib/stores/settings'; +import { + tabConfigurationStore, + developerModeStore, + setDeveloperMode, + resetTabConfiguration, +} from '~/lib/stores/settings'; import { profileStore } from '~/lib/stores/profile'; -import type { TabType, TabVisibilityConfig, DevTabConfig, Profile } from './types'; +import type { TabType, TabVisibilityConfig, Profile } from './types'; import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants'; -import { resetTabConfiguration } from '~/lib/stores/settings'; import { DialogTitle } from '~/components/ui/Dialog'; import { AvatarDropdown } from './AvatarDropdown'; @@ -43,6 +47,24 @@ interface TabWithDevType extends TabVisibilityConfig { isExtraDevTab?: boolean; } +interface ExtendedTabConfig extends TabVisibilityConfig { + isExtraDevTab?: boolean; +} + +interface BaseTabConfig { + id: TabType; + visible: boolean; + window: 'user' | 'developer'; + order: number; +} + +interface AnimatedSwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + id: string; + label: string; +} + const TAB_DESCRIPTIONS: Record = { profile: 'Manage your profile and account settings', settings: 'Configure application preferences', @@ -60,6 +82,65 @@ const TAB_DESCRIPTIONS: Record = { 'tab-management': 'Configure visible tabs and their order', }; +const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => { + return ( +
+ + + + + Toggle {label} + +
+ +
+
+ ); +}; + export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { // State const [activeTab, setActiveTab] = useState(null); @@ -78,7 +159,12 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus(); const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus(); - // Add visibleTabs logic using useMemo + // Memoize the base tab configurations to avoid recalculation + const baseTabConfig = useMemo(() => { + return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab])); + }, []); + + // Add visibleTabs logic using useMemo with optimized calculations const visibleTabs = useMemo(() => { if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) { console.warn('Invalid tab configuration, resetting to defaults'); @@ -87,64 +173,84 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return []; } + const notificationsDisabled = profile?.preferences?.notifications === false; + // In developer mode, show ALL tabs without restrictions if (developerMode) { - // Combine all unique tabs from both user and developer configurations - const allTabs = new Set([ - ...DEFAULT_TAB_CONFIG.map((tab) => tab.id), - ...tabConfiguration.userTabs.map((tab) => tab.id), - ...(tabConfiguration.developerTabs || []).map((tab) => tab.id), - ]); + const seenTabs = new Set(); + const devTabs: ExtendedTabConfig[] = []; - // Create a complete tab list with all tabs visible - const devTabs = Array.from(allTabs).map((tabId) => { - // Try to find existing configuration for this tab - const existingTab = - tabConfiguration.developerTabs?.find((t) => t.id === tabId) || - tabConfiguration.userTabs?.find((t) => t.id === tabId) || - DEFAULT_TAB_CONFIG.find((t) => t.id === tabId); + // Process tabs in order of priority: developer, user, default + const processTab = (tab: BaseTabConfig) => { + if (!seenTabs.has(tab.id)) { + seenTabs.add(tab.id); + devTabs.push({ + id: tab.id, + visible: true, + window: 'developer', + order: tab.order || devTabs.length, + }); + } + }; - return { - id: tabId, - visible: true, - window: 'developer' as const, - order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId), - }; - }); + // Process tabs in priority order + tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig)); + tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig)); + DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig)); - // Add Tab Management tile for developer mode - const tabManagementConfig: DevTabConfig = { - id: 'tab-management', + // Add Tab Management tile + devTabs.push({ + id: 'tab-management' as TabType, visible: true, window: 'developer', order: devTabs.length, isExtraDevTab: true, - }; - devTabs.push(tabManagementConfig); + }); return devTabs.sort((a, b) => a.order - b.order); } - // In user mode, only show visible user tabs - const notificationsDisabled = profile?.preferences?.notifications === false; - + // Optimize user mode tab filtering return tabConfiguration.userTabs .filter((tab) => { - if (!tab || typeof tab.id !== 'string') { - console.warn('Invalid tab entry:', tab); + if (!tab?.id) { return false; } - // Hide notifications tab if notifications are disabled in user preferences if (tab.id === 'notifications' && notificationsDisabled) { return false; } - // Only show tabs that are explicitly visible and assigned to the user window return tab.visible && tab.window === 'user'; }) .sort((a, b) => a.order - b.order); - }, [tabConfiguration, developerMode, profile?.preferences?.notifications]); + }, [tabConfiguration, developerMode, profile?.preferences?.notifications, baseTabConfig]); + + // Optimize animation performance with layout animations + const gridLayoutVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + }; + + const itemVariants = { + hidden: { opacity: 0, scale: 0.8 }, + visible: { + opacity: 1, + scale: 1, + transition: { + type: 'spring', + stiffness: 200, + damping: 20, + mass: 0.6, + }, + }, + }; // Handlers const handleBack = () => { @@ -328,7 +434,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { }} >
-
+
)} @@ -338,39 +444,14 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
- {/* Developer Mode Controls */} -
- {/* Mode Toggle */} -
- - Toggle developer mode - - -
- -
-
+ {/* Mode Toggle */} +
+
{/* Avatar and Dropdown */} @@ -415,24 +496,15 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { ) : activeTab ? ( getTabComponent(activeTab) ) : ( - + {(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => ( - + handleTabClick(tab.id as TabType)} diff --git a/app/components/@settings/tabs/update/UpdateTab.tsx b/app/components/@settings/tabs/update/UpdateTab.tsx index df0f0111..460e625b 100644 --- a/app/components/@settings/tabs/update/UpdateTab.tsx +++ b/app/components/@settings/tabs/update/UpdateTab.tsx @@ -22,6 +22,19 @@ interface GitHubReleaseResponse { }>; } +interface UpdateProgress { + stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete'; + message: string; + progress?: number; + error?: string; + details?: { + changedFiles?: string[]; + additions?: number; + deletions?: number; + commitMessages?: string[]; + }; +} + interface UpdateInfo { currentVersion: string; latestVersion: string; @@ -32,9 +45,7 @@ interface UpdateInfo { changelog?: string[]; currentCommit?: string; latestCommit?: string; - downloadProgress?: number; - installProgress?: number; - estimatedTimeRemaining?: number; + updateProgress?: UpdateProgress; error?: { type: string; message: string; @@ -47,13 +58,6 @@ interface UpdateSettings { checkInterval: number; } -interface UpdateResponse { - success: boolean; - error?: string; - message?: string; - instructions?: string[]; -} - const categorizeChangelog = (messages: string[]) => { const categories = new Map(); @@ -168,7 +172,6 @@ const UpdateTab = () => { const [isChecking, setIsChecking] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [error, setError] = useState(null); - const [retryCount, setRetryCount] = useState(0); const [showChangelog, setShowChangelog] = useState(false); const [showManualInstructions, setShowManualInstructions] = useState(false); const [hasUserRespondedToUpdate, setHasUserRespondedToUpdate] = useState(false); @@ -186,6 +189,7 @@ const UpdateTab = () => { const [lastChecked, setLastChecked] = useState(null); const [showUpdateDialog, setShowUpdateDialog] = useState(false); const [updateChangelog, setUpdateChangelog] = useState([]); + const [updateProgress, setUpdateProgress] = useState(null); useEffect(() => { localStorage.setItem('update_settings', JSON.stringify(updateSettings)); @@ -259,78 +263,105 @@ const UpdateTab = () => { const initiateUpdate = async () => { setIsUpdating(true); setError(null); + setUpdateProgress(null); - let currentRetry = 0; - const maxRetries = 3; + try { + const response = await fetch('/api/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + branch: isLatestBranch ? 'main' : 'stable', + }), + }); - const attemptUpdate = async (): Promise => { - try { - const response = await fetch('/api/update', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - branch: isLatestBranch ? 'main' : 'stable', - }), - }); - - if (!response.ok) { - const errorData = (await response.json()) as { error: string }; - throw new Error(errorData.error || 'Failed to initiate update'); - } - - const result = (await response.json()) as UpdateResponse; - - if (result.success) { - logStore.logSuccess('Update instructions ready', { - type: 'update', - message: result.message || 'Update instructions ready', - }); - - // Show manual update instructions - setShowManualInstructions(true); - setUpdateChangelog( - result.instructions || [ - 'Failed to get update instructions. Please update manually:', - '1. git pull origin main', - '2. pnpm install', - '3. pnpm build', - '4. Restart the application', - ], - ); - - return; - } - - throw new Error(result.error || 'Update failed'); - } catch (err) { - currentRetry++; - - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - - if (currentRetry < maxRetries) { - toast.warning(`Update attempt ${currentRetry} failed. Retrying...`, { autoClose: 2000 }); - setRetryCount(currentRetry); - await new Promise((resolve) => setTimeout(resolve, 2000)); - await attemptUpdate(); - - return; - } - - setError('Failed to get update instructions. Please update manually.'); - console.error('Update failed:', err); - logStore.logSystem('Update failed: ' + errorMessage); - toast.error('Update failed: ' + errorMessage); - setUpdateFailed(true); + if (!response.ok) { + const errorData = (await response.json()) as { error: string }; + throw new Error(errorData.error || 'Failed to initiate update'); } - }; - await attemptUpdate(); - setIsUpdating(false); - setRetryCount(0); + // Handle streaming response + const reader = response.body?.getReader(); + + if (!reader) { + throw new Error('Failed to read response stream'); + } + + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + const chunk = decoder.decode(value); + const updates = chunk.split('\n').filter(Boolean); + + for (const update of updates) { + try { + const progress = JSON.parse(update) as UpdateProgress; + setUpdateProgress(progress); + + if (progress.error) { + throw new Error(progress.error); + } + + if (progress.stage === 'complete') { + logStore.logSuccess('Update completed', { + type: 'update', + message: progress.message, + }); + toast.success(progress.message); + setUpdateFailed(false); + + return; + } + + logStore.logInfo(`Update progress: ${progress.stage}`, { + type: 'update', + message: progress.message, + }); + } catch (e) { + console.error('Failed to parse update progress:', e); + } + } + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; + setError('Failed to complete update. Please try again or update manually.'); + console.error('Update failed:', err); + logStore.logSystem('Update failed: ' + errorMessage); + toast.error('Update failed: ' + errorMessage); + setUpdateFailed(true); + } finally { + setIsUpdating(false); + } }; + const handleRestart = async () => { + // Show confirmation dialog + if (window.confirm('The application needs to restart to apply the update. Proceed?')) { + // Save any necessary state + localStorage.setItem('pendingRestart', 'true'); + + // Reload the page + window.location.reload(); + } + }; + + // Check for pending restart on mount + useEffect(() => { + const pendingRestart = localStorage.getItem('pendingRestart'); + + if (pendingRestart === 'true') { + localStorage.removeItem('pendingRestart'); + toast.success('Update applied successfully!'); + } + }, []); + useEffect(() => { const checkInterval = updateSettings.checkInterval * 60 * 60 * 1000; const intervalId = setInterval(checkForUpdates, checkInterval); @@ -741,7 +772,7 @@ const UpdateTab = () => { )} {/* Update Progress */} - {isUpdating && updateInfo?.downloadProgress !== undefined && ( + {isUpdating && updateProgress && ( { >
- Downloading Update - - {Math.round(updateInfo.downloadProgress)}% - +
+ + {updateProgress.stage.charAt(0).toUpperCase() + updateProgress.stage.slice(1)} + +

{updateProgress.message}

+
+ {updateProgress.progress !== undefined && ( + {Math.round(updateProgress.progress)}% + )}
-
-
-
- {retryCount > 0 &&

Retry attempt {retryCount}/3...

} + + {/* Show detailed information when available */} + {updateProgress.details && ( +
+ {updateProgress.details.commitMessages && updateProgress.details.commitMessages.length > 0 && ( +
+

Commits to be applied:

+
+ {updateProgress.details.commitMessages.map((msg, i) => ( +
+ {msg} +
+ ))} +
+
+ )} + + {updateProgress.details.changedFiles && updateProgress.details.changedFiles.length > 0 && ( +
+

Changed Files:

+
+ {updateProgress.details.changedFiles.map((file, i) => ( +
+ {file} +
+ ))} +
+
+ )} + + {(updateProgress.details.additions !== undefined || updateProgress.details.deletions !== undefined) && ( +
+ {updateProgress.details.additions !== undefined && ( +
+ +{updateProgress.details.additions} additions +
+ )} + {updateProgress.details.deletions !== undefined && ( +
+ -{updateProgress.details.deletions} deletions +
+ )} +
+ )} +
+ )} + + {updateProgress.progress !== undefined && ( +
+
+
+ )} + + {/* Show restart button when update is complete */} + {updateProgress.stage === 'complete' && !updateProgress.error && ( +
+ +
+ )}
)} diff --git a/app/routes/api.update.ts b/app/routes/api.update.ts index 165306fd..75317500 100644 --- a/app/routes/api.update.ts +++ b/app/routes/api.update.ts @@ -1,10 +1,27 @@ import { json } from '@remix-run/node'; import type { ActionFunction } from '@remix-run/node'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); interface UpdateRequestBody { branch: string; } +interface UpdateProgress { + stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete'; + message: string; + progress?: number; + error?: string; + details?: { + changedFiles?: string[]; + additions?: number; + deletions?: number; + commitMessages?: string[]; + }; +} + export const action: ActionFunction = async ({ request }) => { if (request.method !== 'POST') { return json({ error: 'Method not allowed' }, { status: 405 }); @@ -13,24 +30,135 @@ export const action: ActionFunction = async ({ request }) => { try { const body = await request.json(); - // Type guard to check if body has the correct shape if (!body || typeof body !== 'object' || !('branch' in body) || typeof body.branch !== 'string') { return json({ error: 'Invalid request body: branch is required and must be a string' }, { status: 400 }); } const { branch } = body as UpdateRequestBody; - // Instead of direct Git operations, we'll return instructions - return json({ - success: true, - message: 'Please update manually using the following steps:', - instructions: [ - `1. git fetch origin ${branch}`, - `2. git pull origin ${branch}`, - '3. pnpm install', - '4. pnpm build', - '5. Restart the application', - ], + // Create a ReadableStream to send progress updates + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + const sendProgress = (update: UpdateProgress) => { + controller.enqueue(encoder.encode(JSON.stringify(update) + '\n')); + }; + + try { + // Fetch stage + sendProgress({ + stage: 'fetch', + message: 'Fetching latest changes...', + progress: 0, + }); + + // Get current commit hash + const { stdout: currentCommit } = await execAsync('git rev-parse HEAD'); + + // Fetch changes + await execAsync('git fetch origin'); + + // Get list of changed files + const { stdout: diffOutput } = await execAsync(`git diff --name-status origin/${branch}`); + const changedFiles = diffOutput + .split('\n') + .filter(Boolean) + .map((line) => { + const [status, file] = line.split('\t'); + return `${status === 'M' ? 'Modified' : status === 'A' ? 'Added' : 'Deleted'}: ${file}`; + }); + + // Get commit messages + const { stdout: logOutput } = await execAsync(`git log --oneline ${currentCommit.trim()}..origin/${branch}`); + const commitMessages = logOutput.split('\n').filter(Boolean); + + // Get diff stats + const { stdout: diffStats } = await execAsync(`git diff --shortstat origin/${branch}`); + const stats = diffStats.match( + /(\d+) files? changed(?:, (\d+) insertions?\\(\\+\\))?(?:, (\d+) deletions?\\(-\\))?/, + ); + + sendProgress({ + stage: 'fetch', + message: 'Changes detected', + progress: 100, + details: { + changedFiles, + additions: stats?.[2] ? parseInt(stats[2]) : 0, + deletions: stats?.[3] ? parseInt(stats[3]) : 0, + commitMessages, + }, + }); + + // Pull stage + sendProgress({ + stage: 'pull', + message: `Pulling changes from ${branch}...`, + progress: 0, + }); + + await execAsync(`git pull origin ${branch}`); + + sendProgress({ + stage: 'pull', + message: 'Changes pulled successfully', + progress: 100, + }); + + // Install stage + sendProgress({ + stage: 'install', + message: 'Installing dependencies...', + progress: 0, + }); + + await execAsync('pnpm install'); + + sendProgress({ + stage: 'install', + message: 'Dependencies installed successfully', + progress: 100, + }); + + // Build stage + sendProgress({ + stage: 'build', + message: 'Building application...', + progress: 0, + }); + + await execAsync('pnpm build'); + + sendProgress({ + stage: 'build', + message: 'Build completed successfully', + progress: 100, + }); + + // Complete + sendProgress({ + stage: 'complete', + message: 'Update completed successfully! Click Restart to apply changes.', + progress: 100, + }); + } catch (error) { + sendProgress({ + stage: 'complete', + message: 'Update failed', + error: error instanceof Error ? error.message : 'Unknown error occurred', + }); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, }); } catch (error) { console.error('Update preparation failed:', error);