@codeam/ide-native
v0.13.0
Published
React Native UI surface of @codeam/ide — Monaco-backed file viewer, file explorer, source control, search and (Phase 3) extension runtime. Drops into any Expo / RN app as React components; consumers wire their own backend via the @codeam/ide-core adapter
Maintainers
Readme
@codeam/ide-native
VS Code–style IDE surface for React Native — activity bar, file explorer, source control, search, settings, tabs, breadcrumbs, inline editor, diff viewer, and terminal. Same prop shape as
@codeam/ide-web. Bring your own backend.
Quick install
npm install @codeam/ide-native @codeam/ide-core
# Peer deps the package expects you already have:
npm install react react-native react-native-webview react-native-safe-area-context react-native-svg @expo/vector-iconsThe package works with Expo (managed or bare) and plain React Native ≥ 0.74. Monaco + xterm.js are loaded inside a WebView from a CDN at first use, so your app's Metro bundle stays small.
Theme the complete IDE chrome
IDEThemeProvider applies semantic colors, spacing, typography, radii, and minimum touch sizes
to the native IDE surface. Partial overrides inherit safe defaults, so consumers do not need to
copy the complete theme.
import { IDEThemeProvider, IDEShell } from '@codeam/ide-native';
export function BrandedIDE() {
return (
<IDEThemeProvider
theme={{
colors: { accent: '#2563eb', accentMuted: 'rgba(37,99,235,0.2)' },
typography: { monoFamily: 'JetBrainsMono' },
}}
>
<IDEShell {...shellProps}>{editor}</IDEShell>
</IDEThemeProvider>
);
}Interactive controls expose native accessibility roles, labels, selected/busy states, and mobile touch targets. Destructive bulk actions such as Replace All, conflict resolution, uninstall, and closing dirty tabs require confirmation.
Provider failures remain visible and retryable instead of being presented as empty results.
ResilientWebView also waits for Monaco/xterm bridge readiness, retries one reclaimed renderer,
and surfaces a reload action if recovery fails.
What you get
| Component | What it renders | Adapter it consumes |
| -------------------- | -------------------------------------------------------------------- | ------------------------------- |
| IDEShell | Activity bar + side panel + main + responsive drawer (< 600px width) | none |
| ActivityBar | Vertical icon strip | none |
| FileTreeSidebar | VS Code-style explorer using FlatList virtualisation | FileTreeProvider |
| SourceControlPanel | Branch header, commit input, file list, Graph | GitProvider |
| SearchPanel | Multi-file search | SearchProvider |
| SettingsPanel | Theme / font / wrap / minimap / line-numbers controls | SettingsStore (optional) |
| TabsBar | Editor-tab strip with dirty markers | none |
| Breadcrumbs | Clickable path segments | none |
| FileViewerProvider | Context + modal host (chat use case) | FileFetcher |
| FileViewerHost | Fullscreen Monaco editor modal | reads from FileViewerProvider |
| InlineEditor | Inline Monaco editor for the IDE main pane | FileFetcher + SettingsStore |
| DiffViewer | Side-by-side Monaco diff | GitProvider + FileFetcher |
| TerminalPanel | xterm.js terminal inside a WebView | TerminalProvider |
Five-minute integration
import { Button, View } from 'react-native';
import {
FileViewerProvider,
FileViewerHost,
useFileViewer,
type FileFetcher,
} from '@codeam/ide-native';
const fetcher: FileFetcher = {
label: 'demo',
canWrite: false,
async read(path) {
const r = await fetch(`https://api.example.com/files?path=${encodeURIComponent(path)}`);
if (!r.ok) return { error: `HTTP ${r.status}` };
return { content: await r.text() };
},
async write() {
return { error: 'Read-only demo' };
},
};
function OpenButton() {
const { open } = useFileViewer();
return <Button title="Open README" onPress={() => open({ path: 'README.md', op: 'Read' })} />;
}
export default function App() {
return (
<FileViewerProvider fetcher={fetcher}>
<View style={{ flex: 1 }}>
<OpenButton />
<FileViewerHost />
</View>
</FileViewerProvider>
);
}Full IDE shell
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import {
Breadcrumbs,
DiffViewer,
FileTreeSidebar,
IDEShell,
InlineEditor,
SearchPanel,
SettingsPanel,
SourceControlPanel,
TabsBar,
type ActivityBarItem,
type EditorTab,
type FileFetcher,
type FileTreeProvider,
type GitProvider,
type GitStatusEntry,
type SearchProvider,
type SettingsStore,
} from '@codeam/ide-native';
type View = 'files' | 'search' | 'scm' | 'settings';
export function IDEScreen({
fetcher,
fileTree,
git,
search,
settings,
}: {
fetcher: FileFetcher;
fileTree: FileTreeProvider;
git: GitProvider;
search: SearchProvider;
settings: SettingsStore;
}) {
const [view, setView] = useState<View | null>('files');
const [openTabs, setOpenTabs] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<string | null>(null);
const [buffers, setBuffers] = useState<Record<string, string>>({});
const [saved, setSaved] = useState<Record<string, string>>({});
const [diff, setDiff] = useState<{ path: string; staged: boolean } | null>(null);
const iconColor = (id: string) => (view === id ? '#fff' : '#8a909c');
const items: ActivityBarItem[] = [
{
id: 'files',
label: 'Explorer',
icon: <Ionicons name="document-outline" size={22} color={iconColor('files')} />,
},
{
id: 'search',
label: 'Search',
icon: <Ionicons name="search" size={22} color={iconColor('search')} />,
},
{
id: 'scm',
label: 'Source Control',
icon: <Ionicons name="git-branch" size={22} color={iconColor('scm')} />,
},
];
const tabs: EditorTab[] = openTabs.map((p) => ({
id: p,
label: p.split('/').pop() ?? p,
dirty: buffers[p] !== undefined && saved[p] !== undefined && buffers[p] !== saved[p],
}));
const openFile = (path: string) => {
setOpenTabs((p) => (p.includes(path) ? p : [...p, path]));
setActiveTab(path);
setDiff(null);
setView(null); // auto-close the drawer on phones
};
return (
<IDEShell
activityItems={items}
activityBottomItems={[
{
id: 'settings',
label: 'Settings',
icon: <Ionicons name="settings-outline" size={22} color={iconColor('settings')} />,
},
]}
activeView={view}
onViewChange={(id) => setView(id as View | null)}
panels={{
files: <FileTreeSidebar provider={fileTree} selectedPath={activeTab} onSelect={openFile} />,
search: <SearchPanel provider={search} onOpen={(h) => openFile(h.path)} />,
scm: (
<SourceControlPanel
provider={git}
onSelect={(e: GitStatusEntry) => {
setDiff({ path: e.path, staged: e.staged });
setView(null);
}}
/>
),
settings: <SettingsPanel store={settings} />,
}}
>
<TabsBar
tabs={tabs}
activeId={activeTab}
onSelect={setActiveTab}
onClose={(p) => setOpenTabs((x) => x.filter((y) => y !== p))}
/>
<Breadcrumbs path={diff?.path ?? activeTab ?? ''} rootLabel="workspace" />
{diff ? (
<DiffViewer
path={diff.path}
git={git}
fetcher={fetcher}
staged={diff.staged}
onClose={() => setDiff(null)}
/>
) : (
<InlineEditor
fetcher={fetcher}
path={activeTab}
settingsStore={settings}
buffers={buffers}
setBuffers={setBuffers}
saved={saved}
setSaved={setSaved}
/>
)}
</IDEShell>
);
}Persistence — AsyncStorage SettingsStore
The library's SettingsStore contract is platform-agnostic. For RN, back it with AsyncStorage:
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { SettingsStore } from '@codeam/ide-native';
const PREFIX = 'codeam-ide:';
type Listener = (key: string, value: unknown) => void;
const listeners = new Set<Listener>();
export const settings: SettingsStore = {
async get(key) {
const raw = await AsyncStorage.getItem(PREFIX + key);
return raw ? JSON.parse(raw) : undefined;
},
async set(key, value) {
await AsyncStorage.setItem(PREFIX + key, JSON.stringify(value));
for (const l of listeners) l(key, value);
},
watch(cb) {
listeners.add(cb);
return () => listeners.delete(cb);
},
};The SettingsPanel writes, the InlineEditor watches — the editor's theme / font / wrap / minimap / line-number options update live.
useAsyncAdapter — fetching through an adapter in an effect
Every panel in this package loads its data the same way, and the hook packages that shape up for your own components too:
import { runCancellable } from '@codeam/ide-core';
import { useAsyncAdapter } from '@codeam/ide-native';
function BranchBadge({ git }: { git: GitProvider }) {
const { adapterRef, reloadCount, reload } = useAsyncAdapter(git);
const [branch, setBranch] = useState<string | null>(null);
useEffect(
() =>
runCancellable(async (isCancelled) => {
const status = await adapterRef.current.status();
if (isCancelled()) return;
setBranch(status.branch);
}),
[reloadCount, adapterRef],
);
return (
<Pressable onPress={reload}>
<Text>{branch ?? '…'}</Text>
</Pressable>
);
}adapterRefalways holds the newest adapter, so the effect can read it without listing it as a dependency — a consumer that re-allocates its adapter doesn't trigger a refetch storm.reloadCount/reloaddrive re-fetching on demand: the Retry button after a failure, and the refresh after a commit or a replace.
Identical hook, identical semantics in @codeam/ide-web.
Tips
- Memoise adapters with
useMemokeyed by workspace id. Allocating a fresh object on every render makesFileTreeSidebar's load effect refire and the file list flickers. - Mobile drawer —
IDEShellswitches to a slide-over drawer belowmobileBreakpoint(default 600px). On phones in portrait, picking a file auto-closes the drawer (setView(null)). - The activity bar icon
coloris the consumer's responsibility. Pass different colors for active vs inactive items — the library deliberately doesn't wrap your icon withopacitybecause some@expo/vector-iconsbuilds hide the glyph when the parent has opacity < 1. - Monaco + xterm.js are CDN-loaded inside WebViews. First open requires network; subsequent opens hit the WebView cache.
Cross-platform parity
Web equivalent: @codeam/ide-web. The two packages share @codeam/ide-core adapter types, so a single set of providers serves both surfaces.
License
MIT © Edgar Durand
