Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
This commit is contained in:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
@@ -15,6 +15,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { SyncProvider, useSessions } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { SyncRuntimeEffects } from './AppEffects';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts';
|
||||
@@ -65,6 +66,7 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||
const providersCount = useConfigStore((state) => state.providers.length);
|
||||
const agentsCount = useConfigStore((state) => state.agents.length);
|
||||
const sync = useSync();
|
||||
|
||||
React.useEffect(() => {
|
||||
void initializeApp();
|
||||
@@ -130,11 +132,14 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
return;
|
||||
}
|
||||
const session = sessions.find((entry) => entry.id === config.sessionId);
|
||||
if (!session) return;
|
||||
if (!session) {
|
||||
void sync.ensureSessionRenderable(config.sessionId);
|
||||
return;
|
||||
}
|
||||
const directory = (session as { directory?: string | null }).directory ?? config.directory;
|
||||
setCurrentSession(config.sessionId, directory);
|
||||
sessionBootstrappedRef.current = true;
|
||||
}, [config, currentSessionId, sessions, setCurrentSession]);
|
||||
}, [config, currentSessionId, sessions, setCurrentSession, sync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiFileTextLine,
|
||||
RiGitBranchLine,
|
||||
RiMenuLine,
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react';
|
||||
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { SettingsView } from '@/components/views/SettingsView';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { SyncProvider, useSession } from '@/sync/sync-context';
|
||||
|
||||
import { SyncAppEffects } from './AppEffects';
|
||||
import { MobileChangesSurface } from './MobileChangesSurface';
|
||||
import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
|
||||
const MOBILE_SETTINGS_PAGES = [
|
||||
'appearance',
|
||||
'chat',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'git',
|
||||
'magic-prompts',
|
||||
'behavior',
|
||||
'mcp',
|
||||
'providers',
|
||||
'usage',
|
||||
'voice',
|
||||
] as const;
|
||||
|
||||
type MobileAppProps = {
|
||||
apis: RuntimeAPIs;
|
||||
};
|
||||
|
||||
const normalizePath = (value?: string | null): string =>
|
||||
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
const getProjectLabel = (path: string): string => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized) return '';
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
|
||||
};
|
||||
|
||||
type OverflowItem = {
|
||||
key: 'files' | 'changes' | 'settings';
|
||||
Icon: typeof RiFileTextLine;
|
||||
label: string;
|
||||
badge?: number;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const MobileOverflowMenu: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
items: OverflowItem[];
|
||||
}> = ({ open, onClose, items }) => {
|
||||
const { t } = useI18n();
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default bg-[rgb(0_0_0_/_0.25)]"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-2 top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/40 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
|
||||
role="menu"
|
||||
style={{ animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)' }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
||||
index > 0 && 'border-t border-border/30',
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<item.Icon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
|
||||
{item.badge && item.badge > 0 ? (
|
||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<style>{`@keyframes mobile-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileHeader: React.FC<{
|
||||
onOpenSessions: () => void;
|
||||
onOpenMenu: () => void;
|
||||
}> = ({ onOpenSessions, onOpenMenu }) => {
|
||||
const { t } = useI18n();
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const currentSession = useSession(currentSessionId, currentDirectory || undefined);
|
||||
|
||||
const projectLabel = React.useMemo(() => {
|
||||
const directory = normalizePath(currentDirectory);
|
||||
if (!directory) return t('mobile.header.noProject');
|
||||
const project = projects.find((entry) => {
|
||||
const projectPath = normalizePath(entry.path);
|
||||
return directory === projectPath || directory.startsWith(`${projectPath}/`);
|
||||
});
|
||||
return project?.label?.trim() || getProjectLabel(project?.path || directory);
|
||||
}, [currentDirectory, projects, t]);
|
||||
|
||||
const sessionTitle = currentSession?.title?.trim();
|
||||
const primaryLabel = sessionTitle || projectLabel;
|
||||
const secondaryLabel = sessionTitle ? projectLabel : currentSessionId ? t('mobile.sessions.untitled') : '';
|
||||
|
||||
return (
|
||||
<header
|
||||
className="relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={onOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiMenuLine className="size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center rounded-full px-2 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={onOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col leading-tight">
|
||||
<span className="block truncate typography-ui-label text-foreground">{primaryLabel}</span>
|
||||
{secondaryLabel ? (
|
||||
<span className="block truncate typography-micro text-muted-foreground">{secondaryLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMenuAria')}
|
||||
onClick={onOpenMenu}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiMore2Line className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileShell: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false);
|
||||
const [filesOpen, setFilesOpen] = React.useState(false);
|
||||
const [changesOpen, setChangesOpen] = React.useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
const [overflowOpen, setOverflowOpen] = React.useState(false);
|
||||
// When set, the Changes surface opens directly into the per-file diff for this path.
|
||||
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
|
||||
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
|
||||
|
||||
const mobileActions = React.useMemo<MobileAppActions>(
|
||||
() => ({
|
||||
openChanges: ({ diffPath, staged } = {}) => {
|
||||
setPendingChangesDiff(diffPath ? { path: diffPath, staged: staged === true } : null);
|
||||
setChangesOpen(true);
|
||||
},
|
||||
openFiles: () => setFilesOpen(true),
|
||||
openSettings: () => setSettingsOpen(true),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const closeChanges = React.useCallback(() => {
|
||||
setChangesOpen(false);
|
||||
setPendingChangesDiff(null);
|
||||
}, []);
|
||||
|
||||
const overflowItems: OverflowItem[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'files',
|
||||
Icon: RiFileTextLine,
|
||||
label: t('mobile.menu.files'),
|
||||
onSelect: () => setFilesOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'changes',
|
||||
Icon: RiGitBranchLine,
|
||||
label: t('mobile.menu.changes'),
|
||||
badge: dirtyChangeCount,
|
||||
onSelect: () => setChangesOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
Icon: RiSettings3Line,
|
||||
label: t('mobile.menu.settings'),
|
||||
onSelect: () => setSettingsOpen(true),
|
||||
},
|
||||
],
|
||||
[dirtyChangeCount, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<DedicatedMobileAppProvider actions={mobileActions}>
|
||||
<div
|
||||
className="main-content-safe-area flex h-[100dvh] flex-col bg-background text-foreground"
|
||||
data-page-scroll-lock="true"
|
||||
>
|
||||
<MobileHeader
|
||||
onOpenSessions={() => setSessionsSheetOpen(true)}
|
||||
onOpenMenu={() => setOverflowOpen(true)}
|
||||
/>
|
||||
<main className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
|
||||
<ErrorBoundary>
|
||||
<ChatView />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
|
||||
<MobileOverflowMenu
|
||||
open={overflowOpen}
|
||||
onClose={() => setOverflowOpen(false)}
|
||||
items={overflowItems}
|
||||
/>
|
||||
|
||||
{sessionsSheetOpen ? (
|
||||
<MobileSessionsSheet open={sessionsSheetOpen} onOpenChange={setSessionsSheetOpen} />
|
||||
) : null}
|
||||
|
||||
<MobileSurfaceShell
|
||||
open={filesOpen}
|
||||
onClose={() => setFilesOpen(false)}
|
||||
ariaLabel={t('mobile.menu.files')}
|
||||
headerless
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
</MobileSurfaceShell>
|
||||
|
||||
<MobileSurfaceShell
|
||||
open={changesOpen}
|
||||
onClose={closeChanges}
|
||||
ariaLabel={t('mobile.menu.changes')}
|
||||
headerless
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileChangesSurface
|
||||
onClose={closeChanges}
|
||||
initialDiffPath={pendingChangesDiff?.path ?? null}
|
||||
initialDiffStaged={pendingChangesDiff?.staged === true}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileSurfaceShell>
|
||||
|
||||
<MobileSurfaceShell
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
ariaLabel={t('mobile.menu.settings')}
|
||||
headerless
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<SettingsView
|
||||
forceMobile
|
||||
isWindowed
|
||||
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileSurfaceShell>
|
||||
</div>
|
||||
</DedicatedMobileAppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export function MobileApp({ apis }: MobileAppProps) {
|
||||
const initializeApp = useConfigStore((state) => state.initializeApp);
|
||||
const isInitialized = useConfigStore((state) => state.isInitialized);
|
||||
const isConnected = useConfigStore((state) => state.isConnected);
|
||||
const providersCount = useConfigStore((state) => state.providers.length);
|
||||
const agentsCount = useConfigStore((state) => state.agents.length);
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const error = useSessionUIStore((state) => state.error);
|
||||
const clearError = useSessionUIStore((state) => state.clearError);
|
||||
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerRuntimeAPIs(apis);
|
||||
return () => registerRuntimeAPIs(null);
|
||||
}, [apis]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsMobile(true);
|
||||
}, [setIsMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void initializeApp();
|
||||
}, [initializeApp]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
if (providersCount === 0) void loadProviders();
|
||||
if (agentsCount === 0) void loadAgents();
|
||||
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
opencodeClient.setDirectory(currentDirectory);
|
||||
}, [currentDirectory, isConnected]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, refreshGitHubAuthStatus]);
|
||||
|
||||
// Discover all worktrees for every known project so the draft session's
|
||||
// worktree/branch dropdown can list every available branch — not only the
|
||||
// current one. Mirrors ElectronMiniChatApp + desktop SessionSidebar.
|
||||
React.useEffect(() => {
|
||||
if (projects.length === 0) return;
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||
const allWorktrees: WorktreeMetadata[] = [];
|
||||
|
||||
await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!projectPath) return;
|
||||
try {
|
||||
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
||||
const isGitRepo =
|
||||
cachedIsGitRepo ?? (await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath)));
|
||||
if (!isGitRepo) return;
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled || worktrees.length === 0) return;
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
allWorktrees.push(...worktrees);
|
||||
} catch {
|
||||
// Worktree discovery is best-effort; draft selector falls back to the project root.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
});
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projects]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
|
||||
if (!res || !res.ok || cancelled) return;
|
||||
const data = (await res.json().catch(() => null)) as null | { planModeExperimentalEnabled?: unknown };
|
||||
if (!data || cancelled) return;
|
||||
const raw = data.planModeExperimentalEnabled;
|
||||
setPlanModeEnabled(raw === true || raw === 1 || raw === '1' || raw === 'true');
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [setPlanModeEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!error) return;
|
||||
const timeout = window.setTimeout(() => clearError(), 5000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [clearError, error]);
|
||||
|
||||
useAppFontEffects();
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
useRouter();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
||||
<div className="h-full bg-background text-foreground">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={isInitialized} />
|
||||
<MobileShell />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</RuntimeAPIProvider>
|
||||
</SyncProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import React from 'react';
|
||||
import { RiArrowLeftLine, RiCloseLine, RiGitBranchLine, RiLoader4Line } from '@remixicon/react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
|
||||
import { CommitSection } from '@/components/views/git/CommitSection';
|
||||
import { SyncActions } from '@/components/views/git/SyncActions';
|
||||
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
|
||||
import type { GitRemote } from '@/lib/gitApi';
|
||||
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
||||
import {
|
||||
useGitStore,
|
||||
useGitStatus,
|
||||
useIsGitRepo,
|
||||
useGitLoadingStatus,
|
||||
} from '@/stores/useGitStore';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
|
||||
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
||||
const indexStatus = file.index?.trim();
|
||||
return Boolean(indexStatus && indexStatus !== '?');
|
||||
};
|
||||
|
||||
const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
||||
const workingStatus = file.working_dir?.trim();
|
||||
const indexStatus = file.index?.trim();
|
||||
return Boolean(workingStatus || indexStatus === '?');
|
||||
};
|
||||
|
||||
const diffCacheKey = (path: string, staged: boolean): string => staged ? `${path}\u0000staged` : path;
|
||||
|
||||
type MobileChangesSurfaceProps = {
|
||||
/** When provided, the list header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* When set (and non-null), the surface opens directly into the per-file diff view for this
|
||||
* relative path. Updating it (incl. setting it to a different path while open) routes the
|
||||
* surface to that diff. Setting it back to null leaves the user on the current internal route.
|
||||
*/
|
||||
initialDiffPath?: string | null;
|
||||
initialDiffStaged?: boolean;
|
||||
};
|
||||
|
||||
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
|
||||
const { t } = useI18n();
|
||||
const { git } = useRuntimeAPIs();
|
||||
const currentDirectory = normalizePath(useEffectiveDirectory() ?? null);
|
||||
const status = useGitStatus(currentDirectory || null);
|
||||
const isGitRepo = useIsGitRepo(currentDirectory || null);
|
||||
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
|
||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||
const ensureAll = useGitStore((state) => state.ensureAll);
|
||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
|
||||
const getDiff = useGitStore((state) => state.getDiff);
|
||||
const setDiff = useGitStore((state) => state.setDiff);
|
||||
|
||||
const [route, setRoute] = React.useState<{ type: 'list' } | { type: 'diff'; path: string; staged: boolean }>(
|
||||
() => (initialDiffPath ? { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } : { type: 'list' }),
|
||||
);
|
||||
|
||||
// Allow the host (MobileApp) to push us into a specific diff when the surface
|
||||
// is reopened or when an external trigger (e.g. PendingChangesBar tap) requests
|
||||
// a different file mid-session.
|
||||
React.useEffect(() => {
|
||||
if (!initialDiffPath) return;
|
||||
setRoute((current) => (
|
||||
current.type === 'diff' && current.path === initialDiffPath && current.staged === initialDiffStaged
|
||||
? current
|
||||
: { type: 'diff', path: initialDiffPath, staged: initialDiffStaged }
|
||||
));
|
||||
}, [initialDiffPath, initialDiffStaged]);
|
||||
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
||||
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
|
||||
const [commitMessage, setCommitMessage] = React.useState('');
|
||||
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
||||
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>([]);
|
||||
const [visibleChangePaths, setVisibleChangePaths] = React.useState<string[]>([]);
|
||||
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
||||
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||
|
||||
const changeEntries = React.useMemo(() => {
|
||||
const files = status?.files ?? [];
|
||||
const unique = new Map<string, (typeof files)[number]>();
|
||||
for (const file of files) {
|
||||
unique.set(file.path, file);
|
||||
}
|
||||
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [status?.files]);
|
||||
|
||||
const stagedChangeEntries = React.useMemo(
|
||||
() => changeEntries.filter(isStagedStatusFile),
|
||||
[changeEntries],
|
||||
);
|
||||
|
||||
const unstagedChangeEntries = React.useMemo(
|
||||
() => changeEntries.filter(isUnstagedStatusFile),
|
||||
[changeEntries],
|
||||
);
|
||||
|
||||
const effectiveRemotes = React.useMemo<GitRemote[]>(() => {
|
||||
if (remotes.length > 0) return remotes;
|
||||
const trackingRemote = status?.tracking?.includes('/') ? status.tracking.split('/')[0] : null;
|
||||
if (trackingRemote || remoteUrl) {
|
||||
return [{ name: trackingRemote || 'origin', fetchUrl: remoteUrl ?? '', pushUrl: remoteUrl ?? '' }];
|
||||
}
|
||||
return [];
|
||||
}, [remoteUrl, remotes, status?.tracking]);
|
||||
|
||||
const selectedDiff = useGitStore(React.useCallback((state) => {
|
||||
if (!currentDirectory || route.type !== 'diff') return null;
|
||||
return state.directories.get(currentDirectory)?.diffCache.get(diffCacheKey(route.path, route.staged)) ?? null;
|
||||
}, [currentDirectory, route]));
|
||||
|
||||
const selectedFileEntry = React.useMemo(() => {
|
||||
if (route.type !== 'diff') return null;
|
||||
return changeEntries.find((entry) => entry.path === route.path) ?? null;
|
||||
}, [changeEntries, route]);
|
||||
|
||||
const refreshStatusAndBranches = React.useCallback(async (showErrors = true) => {
|
||||
if (!currentDirectory) return;
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchStatus(currentDirectory, git),
|
||||
fetchBranches(currentDirectory, git),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (showErrors) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.refreshRepositoryFailed'));
|
||||
}
|
||||
}
|
||||
}, [currentDirectory, fetchBranches, fetchStatus, git, t]);
|
||||
|
||||
const refreshRemotes = React.useCallback(async () => {
|
||||
if (!currentDirectory) {
|
||||
setRemotes([]);
|
||||
setRemoteUrl(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [remoteList, url] = await Promise.all([
|
||||
git.getRemotes(currentDirectory).catch(() => []),
|
||||
git.getRemoteUrl ? git.getRemoteUrl(currentDirectory).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
setRemotes(remoteList);
|
||||
setRemoteUrl(url);
|
||||
} catch {
|
||||
setRemotes([]);
|
||||
setRemoteUrl(null);
|
||||
}
|
||||
}, [currentDirectory, git]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory) return;
|
||||
setActiveDirectory(currentDirectory);
|
||||
void ensureAll(currentDirectory, git);
|
||||
}, [currentDirectory, ensureAll, git, setActiveDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshRemotes();
|
||||
}, [refreshRemotes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory || changeEntries.length === 0) return;
|
||||
const orderedPaths = Array.from(new Set([
|
||||
...stagedChangeEntries.map((entry) => entry.path),
|
||||
...visibleChangePaths,
|
||||
...changeEntries.slice(0, 20).map((entry) => entry.path),
|
||||
])).filter(Boolean);
|
||||
if (orderedPaths.length === 0) return;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 });
|
||||
}, 120);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'diff') {
|
||||
setDiffLoadError(null);
|
||||
return;
|
||||
}
|
||||
const cacheKey = diffCacheKey(route.path, route.staged);
|
||||
if (!currentDirectory || getDiff(currentDirectory, cacheKey)) {
|
||||
setDiffLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDiffLoadError(null);
|
||||
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setDiff(currentDirectory, cacheKey, {
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setDiffLoadError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]);
|
||||
|
||||
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
|
||||
if (!currentDirectory) return;
|
||||
setSyncAction(action);
|
||||
try {
|
||||
const getPullOptions = (pullRemote: GitRemote) => {
|
||||
const trackingPrefix = `${pullRemote.name}/`;
|
||||
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
||||
? status.tracking.slice(trackingPrefix.length)
|
||||
: undefined;
|
||||
return { remote: pullRemote.name, branch: trackedBranch, rebase: true };
|
||||
};
|
||||
|
||||
if (action === 'fetch') {
|
||||
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name }));
|
||||
} else if (action === 'sync') {
|
||||
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
const afterFetch = await git.getGitStatus(currentDirectory);
|
||||
if ((afterFetch.behind ?? 0) > 0) {
|
||||
if ((afterFetch.files?.length ?? 0) > 0) {
|
||||
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
|
||||
return;
|
||||
}
|
||||
await git.gitPull(currentDirectory, getPullOptions(remote));
|
||||
}
|
||||
const afterPull = await git.getGitStatus(currentDirectory);
|
||||
if ((afterPull.ahead ?? 0) > 0) {
|
||||
await git.gitPush(currentDirectory);
|
||||
}
|
||||
toast.success(t('gitView.toast.alreadyUpToDate'));
|
||||
}
|
||||
await refreshStatusAndBranches(false);
|
||||
await refreshRemotes();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.syncActionFailed', { action: t('gitView.sync.syncChanges') }));
|
||||
} finally {
|
||||
setSyncAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const moveChangePaths = React.useCallback(async (paths: string[], direction: 'stage' | 'unstage') => {
|
||||
if (!currentDirectory || paths.length === 0) return;
|
||||
try {
|
||||
if (direction === 'stage') {
|
||||
if (paths.length > 1) await stageGitFiles(currentDirectory, paths);
|
||||
else await stageGitFile(currentDirectory, paths[0]);
|
||||
} else {
|
||||
if (paths.length > 1) await unstageGitFiles(currentDirectory, paths);
|
||||
else await unstageGitFile(currentDirectory, paths[0]);
|
||||
}
|
||||
await refreshStatusAndBranches(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : direction === 'stage'
|
||||
? t('gitView.toast.stageFileFailed')
|
||||
: t('gitView.toast.unstageFileFailed'));
|
||||
}
|
||||
}, [currentDirectory, refreshStatusAndBranches, t]);
|
||||
|
||||
const handleViewChangeDiff = React.useCallback((path: string, staged = false) => {
|
||||
setRoute({ type: 'diff', path, staged });
|
||||
}, []);
|
||||
|
||||
const handleRevertFile = React.useCallback(async (filePath: string) => {
|
||||
if (!currentDirectory) return;
|
||||
setRevertingPaths((previous) => new Set(previous).add(filePath));
|
||||
try {
|
||||
await git.revertGitFile(currentDirectory, filePath);
|
||||
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
|
||||
await refreshStatusAndBranches(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.revertFailed'));
|
||||
} finally {
|
||||
setRevertingPaths((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(filePath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [currentDirectory, git, refreshStatusAndBranches, t]);
|
||||
|
||||
const handleRevertAll = React.useCallback(async (paths: string[]) => {
|
||||
if (!currentDirectory || paths.length === 0 || isRevertingAll) return;
|
||||
const uniquePaths = Array.from(new Set(paths));
|
||||
setIsRevertingAll(true);
|
||||
setRevertingPaths(new Set(uniquePaths));
|
||||
try {
|
||||
await Promise.all(uniquePaths.map((filePath) => git.revertGitFile(currentDirectory, filePath)));
|
||||
await refreshStatusAndBranches(false);
|
||||
toast.success(uniquePaths.length === 1
|
||||
? t('gitView.toast.revertedFilesSingle', { count: uniquePaths.length })
|
||||
: t('gitView.toast.revertedFilesPlural', { count: uniquePaths.length }));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.revertFailed'));
|
||||
} finally {
|
||||
setRevertingPaths(new Set());
|
||||
setIsRevertingAll(false);
|
||||
}
|
||||
}, [currentDirectory, git, isRevertingAll, refreshStatusAndBranches, t]);
|
||||
|
||||
const handleInsertHighlights = React.useCallback((highlights: string[]) => {
|
||||
const normalized = highlights.map((text) => text.trim()).filter(Boolean);
|
||||
if (normalized.length === 0) {
|
||||
setGeneratedHighlights([]);
|
||||
return;
|
||||
}
|
||||
setCommitMessage((current) => `${current.trim()}${current.trim() ? '\n\n' : ''}${normalized.join('\n')}`.trim());
|
||||
setGeneratedHighlights([]);
|
||||
}, []);
|
||||
|
||||
const handleGenerateCommitMessage = React.useCallback(async () => {
|
||||
if (!currentDirectory) return;
|
||||
const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort();
|
||||
if (selectedFilePaths.length === 0) {
|
||||
toast.error(t('gitView.toast.selectFileToDescribe'));
|
||||
return;
|
||||
}
|
||||
setIsGeneratingMessage(true);
|
||||
try {
|
||||
const { message } = await generateCommitMessage(currentDirectory, selectedFilePaths);
|
||||
setCommitMessage(message.subject?.trim() ?? '');
|
||||
setGeneratedHighlights(Array.isArray(message.highlights) ? message.highlights : []);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.generateCommitMessageFailed'));
|
||||
} finally {
|
||||
setIsGeneratingMessage(false);
|
||||
}
|
||||
}, [currentDirectory, stagedChangeEntries, t]);
|
||||
|
||||
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
|
||||
if (!currentDirectory) return;
|
||||
if (!commitMessage.trim()) {
|
||||
toast.error(t('gitView.toast.enterCommitMessage'));
|
||||
return;
|
||||
}
|
||||
const filesToCommit = stagedChangeEntries.map((file) => file.path).sort();
|
||||
if (filesToCommit.length === 0) {
|
||||
toast.error(t('gitView.toast.selectFileToCommit'));
|
||||
return;
|
||||
}
|
||||
|
||||
setCommitAction(options.pushAfter ? 'commitAndPush' : 'commit');
|
||||
try {
|
||||
await git.createGitCommit(currentDirectory, commitMessage.trim(), { files: filesToCommit });
|
||||
toast.success(t('gitView.toast.commitCreated'));
|
||||
setCommitMessage('');
|
||||
setGeneratedHighlights([]);
|
||||
|
||||
if (options.pushAfter) {
|
||||
const trackingRemoteName = status?.tracking?.split('/')[0];
|
||||
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
|
||||
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
||||
setSyncAction('sync');
|
||||
const trackingPrefix = `${remote.name}/`;
|
||||
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
||||
? status.tracking.slice(trackingPrefix.length)
|
||||
: undefined;
|
||||
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
const afterFetch = await git.getGitStatus(currentDirectory);
|
||||
if ((afterFetch.behind ?? 0) > 0) {
|
||||
await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true });
|
||||
}
|
||||
|
||||
const afterPull = await git.getGitStatus(currentDirectory);
|
||||
if ((afterPull.ahead ?? 0) > 0) {
|
||||
await git.gitPush(currentDirectory);
|
||||
}
|
||||
|
||||
await refreshStatusAndBranches(false);
|
||||
await refreshRemotes();
|
||||
} else {
|
||||
await refreshStatusAndBranches(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.createCommitFailed'));
|
||||
} finally {
|
||||
setCommitAction(null);
|
||||
if (options.pushAfter) setSyncAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const changeGroups = React.useMemo<ChangesGroupConfig[]>(() => {
|
||||
const groups: ChangesGroupConfig[] = [];
|
||||
|
||||
if (stagedChangeEntries.length > 0) {
|
||||
groups.push({
|
||||
id: 'staged',
|
||||
title: t('gitView.changes.stagedTitle'),
|
||||
entries: stagedChangeEntries,
|
||||
actionSymbol: '-',
|
||||
actionAllLabel: t('gitView.changes.unstageAllAria'),
|
||||
getActionLabel: (path: string) => t('gitView.changes.unstageFileAria', { path }),
|
||||
onActionFile: (path: string) => void moveChangePaths([path], 'unstage'),
|
||||
onActionAll: (paths: string[]) => void moveChangePaths(paths, 'unstage'),
|
||||
onViewDiff: (path: string) => handleViewChangeDiff(path, true),
|
||||
onRevertFile: handleRevertFile,
|
||||
showRevertActions: false,
|
||||
accent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (unstagedChangeEntries.length > 0) {
|
||||
groups.push({
|
||||
id: 'unstaged',
|
||||
title: t('gitView.changes.title'),
|
||||
entries: unstagedChangeEntries,
|
||||
actionSymbol: '+',
|
||||
actionAllLabel: t('gitView.changes.stageAllAria'),
|
||||
getActionLabel: (path: string) => t('gitView.changes.stageFileAria', { path }),
|
||||
onActionFile: (path: string) => void moveChangePaths([path], 'stage'),
|
||||
onActionAll: (paths: string[]) => void moveChangePaths(paths, 'stage'),
|
||||
onViewDiff: (path: string) => handleViewChangeDiff(path, false),
|
||||
onRevertFile: handleRevertFile,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [handleRevertFile, handleViewChangeDiff, moveChangePaths, stagedChangeEntries, t, unstagedChangeEntries]);
|
||||
|
||||
if (!currentDirectory) {
|
||||
return <MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />;
|
||||
}
|
||||
|
||||
if (isLoadingStatus && isGitRepo === null) {
|
||||
return <MobileChangesState loading message={t('gitView.loading.checkingRepository')} />;
|
||||
}
|
||||
|
||||
if (isGitRepo === false) {
|
||||
return <MobileChangesState icon message={t('gitView.empty.notGitRepository')} description={t('gitView.empty.notGitRepositoryDescription')} />;
|
||||
}
|
||||
|
||||
if (route.type === 'diff') {
|
||||
return (
|
||||
<MobileDiffDetail
|
||||
path={route.path}
|
||||
diff={selectedDiff}
|
||||
fileExists={Boolean(selectedFileEntry)}
|
||||
error={diffLoadError}
|
||||
onBack={() => setRoute({ type: 'list' })}
|
||||
onRetry={() => setDiffRetryNonce((value) => value + 1)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground">
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
|
||||
<p className="truncate typography-micro text-muted-foreground">
|
||||
{status?.current || currentDirectory}
|
||||
</p>
|
||||
</div>
|
||||
<SyncActions
|
||||
syncAction={syncAction}
|
||||
remotes={effectiveRemotes}
|
||||
onFetch={(remote) => void handleSyncAction('fetch', remote)}
|
||||
onSync={(remote) => void handleSyncAction('sync', remote)}
|
||||
disabled={commitAction !== null || isLoadingStatus}
|
||||
aheadCount={status?.ahead ?? 0}
|
||||
behindCount={status?.behind ?? 0}
|
||||
trackingRemoteName={status?.tracking?.split('/')[0]}
|
||||
hasUncommittedChanges={changeEntries.length > 0}
|
||||
/>
|
||||
</header>
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{changeEntries.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ChangesPanel
|
||||
groups={changeGroups}
|
||||
diffStats={status?.diffStats}
|
||||
revertingPaths={revertingPaths}
|
||||
onRevertAll={handleRevertAll}
|
||||
isRevertingAll={isRevertingAll}
|
||||
headerBackgroundClassName="bg-transparent"
|
||||
onVisiblePathsChange={setVisibleChangePaths}
|
||||
/>
|
||||
<CommitSection
|
||||
stagedCount={stagedChangeEntries.length}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
generatedHighlights={generatedHighlights}
|
||||
onInsertHighlights={handleInsertHighlights}
|
||||
onGenerateMessage={handleGenerateCommitMessage}
|
||||
isGeneratingMessage={isGeneratingMessage}
|
||||
onCommit={() => void handleCommit({ pushAfter: false })}
|
||||
onCommitAndPush={() => void handleCommit({ pushAfter: true })}
|
||||
commitAction={commitAction}
|
||||
gitmojiEnabled={false}
|
||||
onOpenGitmojiPicker={() => {}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
|
||||
)}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileChangesState: React.FC<{
|
||||
message: string;
|
||||
description?: string;
|
||||
loading?: boolean;
|
||||
icon?: boolean;
|
||||
}> = ({ message, description, loading = false, icon = false }) => (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-2">
|
||||
{loading ? <RiLoader4Line className="size-5 animate-spin text-muted-foreground" /> : null}
|
||||
{icon ? <RiGitBranchLine className="size-6 text-muted-foreground" /> : null}
|
||||
<p className="typography-ui-label font-semibold text-foreground">{message}</p>
|
||||
{description ? <p className="typography-meta text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MobileDiffDetail: React.FC<{
|
||||
path: string;
|
||||
diff: { original: string; modified: string; isBinary?: boolean } | null;
|
||||
fileExists: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onRetry: () => void;
|
||||
}> = ({ path, diff, fileExists, error, onBack, onRetry }) => {
|
||||
const { t } = useI18n();
|
||||
const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/50 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 px-2">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{path}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{!fileExists ? (
|
||||
<MobileChangesState icon message={t('mobile.changes.diffDetail.missingTitle')} description={t('mobile.changes.diffDetail.missingDescription')} />
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-3">
|
||||
<p className="typography-ui-label font-semibold text-foreground">{t('mobile.changes.diffDetail.loadFailed')}</p>
|
||||
<p className="typography-meta text-muted-foreground">{error}</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onRetry}>{t('diffView.actions.retry')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : !diff ? (
|
||||
<MobileChangesState loading message={t('diffView.state.loadingDiff')} />
|
||||
) : diff.isBinary ? (
|
||||
<MobileChangesState icon message={t('diffView.binary.unavailable')} />
|
||||
) : isImageFile(path) ? (
|
||||
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
|
||||
) : (
|
||||
<ScrollShadow
|
||||
className="h-full overflow-y-auto overflow-x-hidden p-3"
|
||||
data-diff-virtual-root
|
||||
data-diff-virtual-content
|
||||
>
|
||||
<PierreDiffViewer
|
||||
original={diff.original}
|
||||
modified={diff.modified}
|
||||
language={language}
|
||||
fileName={path}
|
||||
renderSideBySide={false}
|
||||
wrapLines={true}
|
||||
layout="inline"
|
||||
/>
|
||||
</ScrollShadow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,534 @@
|
||||
import React from 'react';
|
||||
import { File as PierreFile } from '@pierre/diffs/react';
|
||||
import {
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightSLine,
|
||||
RiClipboardLine,
|
||||
RiCloseLine,
|
||||
RiFileCopyLine,
|
||||
RiFolder3Fill,
|
||||
RiFolderOpenFill,
|
||||
RiLoader4Line,
|
||||
RiRefreshLine,
|
||||
RiSearchLine,
|
||||
} from '@remixicon/react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { PIERRE_RUNTIME_BASE_CSS } from '@/components/views/PierreDiffViewer';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { getImageMimeType, getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
||||
import type { FileListEntry, FileSearchResult } from '@/lib/api/types';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type MobileFilesRoute =
|
||||
| { type: 'browser'; directory: string }
|
||||
| { type: 'file'; path: string; returnDirectory: string };
|
||||
|
||||
const MAX_MOBILE_FILE_CHARS = 250_000;
|
||||
|
||||
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
const getNameFromPath = (path: string): string => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized || normalized === '/') return normalized || '/';
|
||||
return normalized.split('/').filter(Boolean).at(-1) ?? normalized;
|
||||
};
|
||||
|
||||
const getParentDirectory = (path: string): string | null => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized || normalized === '/') return null;
|
||||
const index = normalized.lastIndexOf('/');
|
||||
if (index <= 0) return normalized.startsWith('/') ? '/' : null;
|
||||
return normalized.slice(0, index);
|
||||
};
|
||||
|
||||
const getRelativePath = (path: string, root: string): string => {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const normalizedRoot = normalizePath(root);
|
||||
if (!normalizedRoot || normalizedPath === normalizedRoot) return getNameFromPath(normalizedPath);
|
||||
if (normalizedPath.startsWith(`${normalizedRoot}/`)) return normalizedPath.slice(normalizedRoot.length + 1);
|
||||
return normalizedPath;
|
||||
};
|
||||
|
||||
const formatFileSize = (size?: number): string => {
|
||||
if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) return '';
|
||||
if (size < 1024) return `${size} B`;
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let value = size / 1024;
|
||||
for (const unit of units) {
|
||||
if (value < 1024 || unit === units[units.length - 1]) return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`;
|
||||
value /= 1024;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const getImageSrc = (path: string): string => {
|
||||
if (path.toLowerCase().endsWith('.svg')) {
|
||||
return '';
|
||||
}
|
||||
return getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path });
|
||||
};
|
||||
|
||||
const isMarkdownFile = (path: string): boolean => /\.(md|mdx|markdown)$/i.test(path);
|
||||
const isJsonFile = (path: string): boolean => /\.(json|jsonc)$/i.test(path);
|
||||
|
||||
type MobileFilesSurfaceProps = {
|
||||
/** When provided, header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose }) => {
|
||||
const { t } = useI18n();
|
||||
const { files } = useRuntimeAPIs();
|
||||
const root = normalizePath(useEffectiveDirectory() ?? null);
|
||||
const [route, setRoute] = React.useState<MobileFilesRoute>(() => ({ type: 'browser', directory: root }));
|
||||
const [entries, setEntries] = React.useState<FileListEntry[]>([]);
|
||||
const [isLoadingDirectory, setIsLoadingDirectory] = React.useState(false);
|
||||
const [directoryError, setDirectoryError] = React.useState<string | null>(null);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [searchResults, setSearchResults] = React.useState<FileSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = React.useState(false);
|
||||
const [fileContent, setFileContent] = React.useState('');
|
||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
|
||||
const directoryLoadRequestIdRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!root) return;
|
||||
setRoute((current) => {
|
||||
if (current.type === 'browser' && current.directory) return current;
|
||||
return { type: 'browser', directory: root };
|
||||
});
|
||||
}, [root]);
|
||||
|
||||
const currentDirectory = route.type === 'browser' ? route.directory : route.returnDirectory;
|
||||
|
||||
const loadDirectory = React.useCallback(async (directory: string) => {
|
||||
if (!directory) return;
|
||||
const requestId = directoryLoadRequestIdRef.current + 1;
|
||||
directoryLoadRequestIdRef.current = requestId;
|
||||
setIsLoadingDirectory(true);
|
||||
setDirectoryError(null);
|
||||
try {
|
||||
const result = await files.listDirectory(directory);
|
||||
if (directoryLoadRequestIdRef.current !== requestId) return;
|
||||
setEntries(result.entries.slice().sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}));
|
||||
} catch (error) {
|
||||
if (directoryLoadRequestIdRef.current !== requestId) return;
|
||||
setEntries([]);
|
||||
setDirectoryError(error instanceof Error ? error.message : t('mobile.files.error.listFailed'));
|
||||
} finally {
|
||||
if (directoryLoadRequestIdRef.current === requestId) {
|
||||
setIsLoadingDirectory(false);
|
||||
}
|
||||
}
|
||||
}, [files, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'browser') return;
|
||||
void loadDirectory(route.directory);
|
||||
}, [loadDirectory, route]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'browser') return;
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsSearching(true);
|
||||
void files.search({ directory: route.directory, query: normalizedQuery, maxResults: 40 })
|
||||
.then((results) => {
|
||||
if (!cancelled) setSearchResults(results);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSearchResults([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsSearching(false);
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [files, query, route]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'file') return;
|
||||
setFileContent('');
|
||||
setFileError(null);
|
||||
|
||||
if (isImageFile(route.path) && !route.path.toLowerCase().endsWith('.svg')) {
|
||||
setIsLoadingFile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files.readFile) {
|
||||
setFileError(t('mobile.files.error.readUnavailable'));
|
||||
setIsLoadingFile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingFile(true);
|
||||
void files.readFile(route.path)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setFileContent(result.content.length > MAX_MOBILE_FILE_CHARS
|
||||
? `${result.content.slice(0, MAX_MOBILE_FILE_CHARS)}\n\n${t('mobile.files.file.truncated')}`
|
||||
: result.content);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoadingFile(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [files, route, t]);
|
||||
|
||||
const openDirectory = (directory: string) => {
|
||||
setQuery('');
|
||||
setRoute({ type: 'browser', directory });
|
||||
};
|
||||
|
||||
const openFile = (path: string) => {
|
||||
setRoute({ type: 'file', path, returnDirectory: currentDirectory || root });
|
||||
};
|
||||
|
||||
const handleCopyPath = async (path: string) => {
|
||||
const result = await copyTextToClipboard(path);
|
||||
if (result.ok) toast.success(t('mobile.files.toast.pathCopied'));
|
||||
else toast.error(t('mobile.files.toast.copyFailed'));
|
||||
};
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) toast.success(t('mobile.files.toast.contentCopied'));
|
||||
else toast.error(t('mobile.files.toast.copyFailed'));
|
||||
};
|
||||
|
||||
if (!root) {
|
||||
return <MobileFilesState message={t('mobile.files.empty.noDirectory')} />;
|
||||
}
|
||||
|
||||
if (route.type === 'file') {
|
||||
return (
|
||||
<MobileFileDetail
|
||||
path={route.path}
|
||||
content={fileContent}
|
||||
error={fileError}
|
||||
isLoading={isLoadingFile}
|
||||
onBack={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
|
||||
onCopyPath={() => void handleCopyPath(route.path)}
|
||||
onCopyContent={() => void handleCopyContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const directoryLabel = route.directory === root ? t('mobile.files.rootDirectory') : getNameFromPath(route.directory);
|
||||
const visibleSearchResults = query.trim() ? searchResults : [];
|
||||
|
||||
// Cap parent navigation at the project root: only allow stepping up while
|
||||
// the parent stays inside (or equal to) the root.
|
||||
const rawParent = getParentDirectory(route.directory);
|
||||
const parentWithinRoot =
|
||||
route.directory !== root && rawParent !== null && (rawParent === root || rawParent.startsWith(`${root}/`));
|
||||
const canGoBack = parentWithinRoot && !query.trim();
|
||||
const parentDirectory = parentWithinRoot ? rawParent : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground">
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
{canGoBack && parentDirectory ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.files.backToParentAria', { name: getNameFromPath(parentDirectory) })}
|
||||
onClick={() => openDirectory(parentDirectory)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<h2 className="truncate typography-ui-label text-foreground">{directoryLabel}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.files.refreshAria')}
|
||||
onClick={() => void loadDirectory(route.directory)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiRefreshLine className={cn('size-5', isLoadingDirectory && 'animate-spin')} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="shrink-0 px-4 pb-2 pt-1">
|
||||
<div className="relative">
|
||||
<RiSearchLine className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('mobile.files.search.placeholder')}
|
||||
className="h-11 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
|
||||
{directoryError ? (
|
||||
<MobileFilesState message={directoryError} />
|
||||
) : query.trim() ? (
|
||||
<MobileSearchResults results={visibleSearchResults} isSearching={isSearching} onOpenFile={openFile} />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
|
||||
{entries.length === 0 && !isLoadingDirectory ? (
|
||||
<div className="px-4 py-8 text-center typography-body text-muted-foreground">{t('mobile.files.empty.directory')}</div>
|
||||
) : null}
|
||||
{entries.map((entry) => (
|
||||
<MobileFileRow
|
||||
key={entry.path}
|
||||
name={entry.name}
|
||||
path={entry.path}
|
||||
directory={entry.isDirectory}
|
||||
meta={entry.isDirectory ? undefined : formatFileSize(entry.size)}
|
||||
onClick={() => entry.isDirectory ? openDirectory(entry.path) : openFile(entry.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFileRow: React.FC<{
|
||||
name: string;
|
||||
path: string;
|
||||
directory: boolean;
|
||||
meta?: string;
|
||||
onClick: () => void;
|
||||
}> = ({ name, path, directory, meta, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/30 px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
onClick={onClick}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
{directory ? (
|
||||
<RiFolder3Fill className="size-5 shrink-0 text-primary/80" />
|
||||
) : (
|
||||
<FileTypeIcon filePath={path} className="size-5 shrink-0" />
|
||||
)}
|
||||
<span className="block min-w-0 flex-1 truncate typography-ui-label text-foreground">{name}</span>
|
||||
{meta ? <span className="shrink-0 typography-micro text-muted-foreground">{meta}</span> : null}
|
||||
{directory ? <RiArrowRightSLine className="size-4 shrink-0 text-muted-foreground/60" /> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
const MobileSearchResults: React.FC<{
|
||||
results: FileSearchResult[];
|
||||
isSearching: boolean;
|
||||
onOpenFile: (path: string) => void;
|
||||
}> = ({ results, isSearching, onOpenFile }) => {
|
||||
const { t } = useI18n();
|
||||
const root = normalizePath(useEffectiveDirectory() ?? null);
|
||||
if (isSearching) return <MobileFilesState loading message={t('common.loading')} />;
|
||||
if (results.length === 0) return <MobileFilesState message={t('mobile.files.search.empty')} />;
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
|
||||
{results.map((result) => (
|
||||
<MobileFileRow
|
||||
key={result.path}
|
||||
name={getNameFromPath(result.path)}
|
||||
path={result.path}
|
||||
directory={false}
|
||||
meta={getRelativePath(result.path, root)}
|
||||
onClick={() => onOpenFile(result.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFileDetail: React.FC<{
|
||||
path: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
onBack: () => void;
|
||||
onCopyPath: () => void;
|
||||
onCopyContent: () => void;
|
||||
}> = ({ path, content, error, isLoading, onBack, onCopyPath, onCopyContent }) => {
|
||||
const { t } = useI18n();
|
||||
const imageAuthKey = isImageFile(path) && !path.toLowerCase().endsWith('.svg') ? path : '';
|
||||
const [imageAuthReadyKey, setImageAuthReadyKey] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!imageAuthKey) {
|
||||
setImageAuthReadyKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setImageAuthReadyKey('');
|
||||
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
||||
.then((token) => {
|
||||
if (!cancelled && token) setImageAuthReadyKey(imageAuthKey);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [imageAuthKey]);
|
||||
|
||||
const imageAuthLoading = Boolean(imageAuthKey && imageAuthReadyKey !== imageAuthKey);
|
||||
const imageSrc = imageAuthLoading ? '' : getImageSrc(path);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/50 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(path)}</h2>
|
||||
</div>
|
||||
{!isImageFile(path) ? (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyContent} aria-label={t('mobile.files.copyContentAria')}>
|
||||
<RiFileCopyLine className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyPath} aria-label={t('mobile.files.copyPathAria')}>
|
||||
<RiClipboardLine className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{isLoading || imageAuthLoading ? (
|
||||
<MobileFilesState loading message={t('filesView.state.loading')} />
|
||||
) : error ? (
|
||||
<MobileFilesState message={error} />
|
||||
) : isImageFile(path) && imageSrc ? (
|
||||
<ScrollShadow className="h-full overflow-auto p-4">
|
||||
<img src={imageSrc} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||
</ScrollShadow>
|
||||
) : isImageFile(path) ? (
|
||||
<ScrollShadow className="h-full overflow-auto p-4">
|
||||
<img src={`data:${getImageMimeType(path)};utf8,${encodeURIComponent(content)}`} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||
</ScrollShadow>
|
||||
) : (
|
||||
<MobileTextFile path={path} content={content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileTextFile: React.FC<{ path: string; content: string }> = ({ path, content }) => {
|
||||
const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem();
|
||||
const lightTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
|
||||
[availableThemes, lightThemeId],
|
||||
);
|
||||
const darkTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? getDefaultTheme(true),
|
||||
[availableThemes, darkThemeId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
ensurePierreThemeRegistered(lightTheme);
|
||||
ensurePierreThemeRegistered(darkTheme);
|
||||
}, [darkTheme, lightTheme]);
|
||||
|
||||
const pierreTheme = React.useMemo(
|
||||
() => ({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id }),
|
||||
[darkTheme.metadata.id, lightTheme.metadata.id],
|
||||
);
|
||||
|
||||
if (isMarkdownFile(path)) {
|
||||
return (
|
||||
<ScrollShadow className="h-full overflow-y-auto px-4 py-4">
|
||||
<SimpleMarkdownRenderer content={content} />
|
||||
</ScrollShadow>
|
||||
);
|
||||
}
|
||||
if (isJsonFile(path)) {
|
||||
return <JsonTreeView jsonString={content} className="h-full overflow-auto" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-auto bg-[var(--syntax-base-background)]">
|
||||
<PierreFile
|
||||
file={{
|
||||
name: getNameFromPath(path),
|
||||
contents: content,
|
||||
lang: getLanguageFromExtension(path) || undefined,
|
||||
}}
|
||||
options={{
|
||||
disableFileHeader: true,
|
||||
overflow: 'wrap',
|
||||
theme: pierreTheme,
|
||||
themeType: currentTheme.metadata.variant === 'dark' ? 'dark' : 'light',
|
||||
unsafeCSS: PIERRE_RUNTIME_BASE_CSS,
|
||||
}}
|
||||
className="block min-h-full w-full"
|
||||
style={{ minHeight: '100%' }}
|
||||
/>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFilesState: React.FC<{ message: string; loading?: boolean }> = ({ message, loading = false }) => (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-2">
|
||||
{loading ? <RiLoader4Line className="size-5 animate-spin text-muted-foreground" /> : <RiFolderOpenFill className="size-6 text-muted-foreground" />}
|
||||
<p className="typography-ui-label font-semibold text-foreground">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react';
|
||||
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SURFACE_ROOT_ID = 'mobile-surface-root';
|
||||
const DISMISS_THRESHOLD_PX = 90;
|
||||
const ENTER_DELAY_MS = 16;
|
||||
|
||||
const ensureSurfaceRoot = (): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
let root = document.getElementById(SURFACE_ROOT_ID);
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = SURFACE_ROOT_ID;
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
export type MobileSurfaceShellProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
/** When set, the leading icon becomes a back arrow that calls this. Otherwise it's a close X bound to onClose. */
|
||||
onBack?: () => void;
|
||||
/** If true, disable swipe-down-to-dismiss (e.g. when a nested view should keep gesture for itself). */
|
||||
disableSwipeDismiss?: boolean;
|
||||
/** If true, render only the drag handle and let the child render its own header. */
|
||||
headerless?: boolean;
|
||||
ariaLabel?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
onBack,
|
||||
disableSwipeDismiss = false,
|
||||
headerless = false,
|
||||
ariaLabel,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [entered, setEntered] = React.useState(false);
|
||||
const [dragOffset, setDragOffset] = React.useState(0);
|
||||
const dragStartYRef = React.useRef<number | null>(null);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const surfaceRef = React.useRef<HTMLElement | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
rootRef.current = ensureSurfaceRoot();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
setEntered(false);
|
||||
const id = window.setTimeout(() => setMounted(false), 220);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const focusFirstElement = () => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = surface.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
(focusable ?? surface).focus({ preventScroll: true });
|
||||
};
|
||||
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = Array.from(surface.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)).filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true');
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
surface.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocusRef.current?.focus?.({ preventScroll: true });
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
const handleDragStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disableSwipeDismiss) return;
|
||||
dragStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
isDraggingRef.current = true;
|
||||
};
|
||||
|
||||
const handleDragMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!isDraggingRef.current || dragStartYRef.current == null) return;
|
||||
const currentY = event.touches[0]?.clientY ?? dragStartYRef.current;
|
||||
const delta = currentY - dragStartYRef.current;
|
||||
setDragOffset(delta > 0 ? delta : 0);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
if (dragOffset >= DISMISS_THRESHOLD_PX) {
|
||||
setDragOffset(0);
|
||||
onClose();
|
||||
} else {
|
||||
setDragOffset(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || !rootRef.current) return null;
|
||||
|
||||
const leading = onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
const visualTransform = entered
|
||||
? `translateY(${dragOffset}px)`
|
||||
: 'translateY(100%)';
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 flex items-end',
|
||||
'bg-[rgb(0_0_0_/_0.45)]',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
entered ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
className="relative flex h-[100dvh] w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground shadow-[0_-12px_48px_rgb(0_0_0_/_0.35)] will-change-transform"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
transform: visualTransform,
|
||||
transition: isDraggingRef.current
|
||||
? 'none'
|
||||
: 'transform 220ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shrink-0 select-none"
|
||||
onTouchStart={handleDragStart}
|
||||
onTouchMove={handleDragMove}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onTouchCancel={handleDragEnd}
|
||||
>
|
||||
<div className="flex items-center justify-center pt-2 pb-1">
|
||||
<span className="h-1 w-10 rounded-full bg-[var(--surface-muted)]" aria-hidden />
|
||||
</div>
|
||||
{!headerless ? (
|
||||
<header className="flex h-[var(--oc-header-height,56px)] items-center gap-2 px-3">
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ? (
|
||||
typeof title === 'string' ? (
|
||||
<h2 className="truncate typography-ui-label text-foreground">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
typeof subtitle === 'string' ? (
|
||||
<p className="truncate typography-micro text-muted-foreground">{subtitle}</p>
|
||||
) : (
|
||||
subtitle
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{trailing ? <div className="flex shrink-0 items-center gap-1.5">{trailing}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden" style={{ paddingBottom: 'var(--oc-safe-area-bottom, 0px)' }}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { useRouter } from '@/hooks/useRouter';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -70,7 +71,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
|
||||
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
|
||||
if (!res || !res.ok || cancelled) return;
|
||||
const data = (await res.json().catch(() => null)) as null | {
|
||||
planModeExperimentalEnabled?: unknown;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React from 'react';
|
||||
|
||||
export type MobileAppActions = {
|
||||
/** Open the Changes surface as a modal and (optionally) navigate it to a specific diff. */
|
||||
openChanges: (options?: { diffPath?: string | null; staged?: boolean }) => void;
|
||||
/** Open the Files surface as a modal. */
|
||||
openFiles: () => void;
|
||||
/** Open the Settings surface as a modal. */
|
||||
openSettings: () => void;
|
||||
};
|
||||
|
||||
const DedicatedMobileAppContext = React.createContext<MobileAppActions | null>(null);
|
||||
|
||||
export const DedicatedMobileAppProvider: React.FC<{
|
||||
actions: MobileAppActions;
|
||||
children: React.ReactNode;
|
||||
}> = ({ actions, children }) => (
|
||||
<DedicatedMobileAppContext.Provider value={actions}>{children}</DedicatedMobileAppContext.Provider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true when the surrounding tree is the dedicated MobileApp root
|
||||
* (Capacitor or hosted /mobile.html), as opposed to the desktop responsive
|
||||
* mobile path. Use this to suppress UI that exists only to bridge the
|
||||
* desktop sidebar/layout into mobile, since the dedicated mobile root has
|
||||
* its own native-feeling navigation and no sidebars to bridge into.
|
||||
*/
|
||||
export const useIsDedicatedMobileApp = (): boolean => React.useContext(DedicatedMobileAppContext) !== null;
|
||||
|
||||
/**
|
||||
* Returns the dedicated mobile app's surface-opening actions, or null when
|
||||
* not inside the dedicated mobile root. Components living in shared chat /
|
||||
* input code can use this to route navigation to mobile-native surfaces
|
||||
* (e.g. open the Changes diff for a file from PendingChangesBar) instead of
|
||||
* desktop sidebars.
|
||||
*/
|
||||
export const useMobileAppActions = (): MobileAppActions | null => React.useContext(DedicatedMobileAppContext);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@/styles/fonts';
|
||||
import '@/index.css';
|
||||
import '@/lib/debug';
|
||||
import { SessionAuthGate } from '@/components/auth/SessionAuthGate';
|
||||
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
||||
import { ThemeProvider } from '@/components/providers/ThemeProvider';
|
||||
import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
|
||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||
import { initializeLocale, I18nProvider } from '@/lib/i18n';
|
||||
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
|
||||
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||
import { startTypographyWatcher } from '@/lib/typographyWatcher';
|
||||
import { MobileApp } from './MobileApp';
|
||||
|
||||
const initializeSharedPreferences = () => {
|
||||
initializeLocale();
|
||||
|
||||
void initializeAppearancePreferences().then(() => {
|
||||
void Promise.all([
|
||||
syncDesktopSettings(),
|
||||
applyPersistedDirectoryPreferences(),
|
||||
]).catch((err) => {
|
||||
console.error('[mobile-main] settings init failed:', err);
|
||||
});
|
||||
|
||||
startAppearanceAutoSave();
|
||||
startModelPrefsAutoSave();
|
||||
startTypographyWatcher();
|
||||
}).catch((err) => {
|
||||
console.error('[mobile-main] appearance init failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
export function renderMobileApp(apis: RuntimeAPIs) {
|
||||
initializeSharedPreferences();
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<ThemeSystemProvider>
|
||||
<ThemeProvider>
|
||||
<DiffWorkerProvider>
|
||||
<SessionAuthGate>
|
||||
<MobileApp apis={apis} />
|
||||
</SessionAuthGate>
|
||||
</DiffWorkerProvider>
|
||||
</ThemeProvider>
|
||||
</ThemeSystemProvider>
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user