Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 131 additions & 80 deletions apps/mobile/src/app/(tabs)/terminals/index.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,42 @@
import {
Button,
ContentUnavailableView,
Form,
Host,
HStack,
ProgressView,
Section,
Text,
TextField,
useNativeState,
} from '@expo/ui/swift-ui';
import {
autocorrectionDisabled,
disabled,
foregroundStyle,
refreshable,
textInputAutocapitalization,
} from '@expo/ui/swift-ui/modifiers';
import { foregroundStyle, refreshable } from '@expo/ui/swift-ui/modifiers';
import { useLinkCodeClient } from '@linkcode/client-core';
import type { TerminalMetadata } from '@linkcode/schema';
import { repositoryLabel } from '@linkcode/ui/native';
import { NavigationRow } from '@mobile/components/form/navigation-row';
import { HostClientGate } from '@mobile/components/host/host-client-gate';
import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items';
import { Stack, useRouter } from 'expo-router';
import { useEffect } from 'foxact/use-abortable-effect';
import { HeaderIconButton } from '@mobile/components/shell/header-icon-button';
import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet';
import { useHostConnection } from '@mobile/runtime/host-connection';
import { Stack, useFocusEffect, useRouter } from 'expo-router';
import { extractErrorMessage } from 'foxts/extract-error-message';
import { PlusIcon } from 'lucide-react-native';
import { useCallback, useState } from 'react';
import { View } from 'react-native';
import { Platform, View } from 'react-native';
import { useTranslations } from 'use-intl';

const INITIAL_TERMINAL_SIZE = { cols: 80, rows: 24 };

const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' });
const SUPPORTS_CONTENT_UNAVAILABLE_VIEW =
Platform.OS === 'ios' && Number.parseInt(Platform.Version, 10) >= 17;

/** Header above the gate, for the same reason as the threads tab: the host switcher has to stay
* reachable when the host is not. */
export default function TerminalsRoute(): React.ReactNode {
const t = useTranslations('mobile.terminals');
const hostMenuItems = useHostMenuItems();
const connection = useHostConnection();
const [sheetOpen, setSheetOpen] = useState(false);

// The flex container is load-bearing: a SwiftUI host left as the screen's direct child is
// proposed the whole window and paints straight over the large title.
Expand All @@ -48,54 +48,78 @@ export default function TerminalsRoute(): React.ReactNode {
headerLargeTitle: true,
title: t('title'),
unstable_headerLeftItems: () => hostMenuItems,
headerRight:
connection?.status === 'ready'
? () => (
<HeaderIconButton
icon={PlusIcon}
label={t('newTerminal')}
onPress={() => setSheetOpen(true)}
/>
)
: undefined,
}}
/>
<HostClientGate>
<TerminalsScreen />
<TerminalsScreen
key={connection?.host.id}
sheetOpen={sheetOpen}
onSheetOpenChange={setSheetOpen}
/>
</HostClientGate>
</View>
);
}

/** Host terminal inbox: attach to a running PTY or start a new one on the host. */
function TerminalsScreen(): React.ReactNode {
function TerminalsScreen({
sheetOpen,
onSheetOpenChange,
}: {
sheetOpen: boolean;
onSheetOpenChange: (open: boolean) => void;
}): React.ReactNode {
const t = useTranslations('mobile.terminals');
const router = useRouter();
const client = useLinkCodeClient();
const [terminals, setTerminals] = useState<TerminalMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Native-backed so the value read on create is the field's own, not a mirrored copy.
const cwd = useNativeState('');
const [loadError, setLoadError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);

const load = useCallback(() => client.listTerminals(), [client]);
const load = useCallback(
async () => (await client.listTerminals()).sort((a, b) => b.createdAt - a.createdAt),
[client],
);

useEffect(
(signal) => {
useFocusEffect(
useCallback(() => {
let active = true;
setLoadError(null);
void load()
.then((nextTerminals) => {
if (!signal.aborted) setTerminals(nextTerminals);
if (active) setTerminals(nextTerminals);
})
.catch((error_: unknown) => {
if (!signal.aborted) {
setError(extractErrorMessage(error_, false) ?? 'Unknown error');
}
if (active) setLoadError(extractErrorMessage(error_, false) ?? 'Unknown error');
})
.finally(() => {
if (!signal.aborted) setLoading(false);
if (active) setLoading(false);
});
},
[load],
return () => {
active = false;
};
}, [load]),
);

// Drives SwiftUI's own pull-to-refresh, which keeps its spinner until this resolves.
const onRefresh = async () => {
setError(null);
setLoadError(null);
try {
setTerminals(await load());
} catch (error_) {
setError(extractErrorMessage(error_, false) ?? 'Unknown error');
setLoadError(extractErrorMessage(error_, false) ?? 'Unknown error');
}
};

Expand All @@ -104,71 +128,98 @@ function TerminalsScreen(): React.ReactNode {
router.push(`/terminal/${encodeURIComponent(terminalId)}${query}`);
};

const onCreate = async () => {
if (creating) return;
const onCreate = async (cwd: string): Promise<boolean> => {
if (creating) return false;
setCreating(true);
setError(null);
setCreateError(null);
try {
const trimmedCwd = cwd.get().trim();
const terminalId = await client.openTerminal({
...INITIAL_TERMINAL_SIZE,
cwd: trimmedCwd || undefined,
cwd: cwd || undefined,
});
client.detachTerminal(terminalId);
cwd.set('');
onSheetOpenChange(false);
openTerminal(terminalId, true);
return true;
} catch (error_) {
setError(extractErrorMessage(error_, false) ?? 'Unknown error');
setCreateError(extractErrorMessage(error_, false) ?? 'Unknown error');
return false;
} finally {
setCreating(false);
}
};

return (
// Form needs the viewport as its proposed size, otherwise it collapses to its content.
<Host style={{ flex: 1 }} useViewportSizeMeasurement>
<Form modifiers={[refreshable(onRefresh)]}>
{error ? (
<Section>
<Text modifiers={[foregroundStyle('red')]}>{t('error', { error })}</Text>
</Section>
) : null}

<Section>
{loading ? (
<ProgressView />
) : terminals.length === 0 ? (
<Text modifiers={[SECONDARY]}>{t('emptyHint')}</Text>
) : (
terminals.map((terminal) => (
<NavigationRow
key={terminal.terminalId}
title={terminal.shell ?? terminal.terminalId.slice(0, 8)}
subtitle={`${terminal.cwd ?? t('unknownCwd')} · ${terminal.cols}×${terminal.rows}`}
badgeText={terminal.controllerAttachmentId ? t('controlled') : undefined}
onPress={() => openTerminal(terminal.terminalId)}
<>
{/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */}
<Host style={{ flex: 1 }} useViewportSizeMeasurement>
{loading ? (
<ProgressView />
) : loadError && terminals.length === 0 ? (
<Form>
<Section>
<Text modifiers={[foregroundStyle('red')]}>
{t('loadError', { error: loadError })}
</Text>
<Button
label={t('retry')}
onPress={() => {
setLoading(true);
void onRefresh().finally(() => setLoading(false));
}}
/>
))
)}
</Section>

<Section title={t('newTerminal')}>
<HStack spacing={12}>
<Text>{t('cwdLabel')}</Text>
<TextField
testID="terminal-cwd-input"
text={cwd}
placeholder={t('cwdPlaceholder')}
modifiers={[textInputAutocapitalization('never'), autocorrectionDisabled()]}
</Section>
</Form>
) : terminals.length === 0 ? (
SUPPORTS_CONTENT_UNAVAILABLE_VIEW ? (
<ContentUnavailableView
title={t('emptyTitle')}
systemImage="terminal"
description={t('emptyHint')}
modifiers={[refreshable(onRefresh)]}
/>
Comment thread
AprilNEA marked this conversation as resolved.
</HStack>
<Button
label={creating ? t('creating') : t('create')}
onPress={onCreate}
modifiers={[disabled(creating)]}
/>
</Section>
</Form>
</Host>
) : (
<Form modifiers={[refreshable(onRefresh)]}>
<Section>
<Text modifiers={[SECONDARY]}>{t('emptyHint')}</Text>
</Section>
</Form>
)
) : (
<Form modifiers={[refreshable(onRefresh)]}>
{loadError ? (
<Section>
<Text modifiers={[foregroundStyle('red')]}>
{t('loadError', { error: loadError })}
</Text>
</Section>
) : null}
<Section>
{terminals.map((terminal) => (
<NavigationRow
key={terminal.terminalId}
title={
terminal.cwd ? repositoryLabel(terminal.cwd) : terminal.terminalId.slice(0, 8)
}
subtitle={`${terminal.shell ? repositoryLabel(terminal.shell) : terminal.terminalId.slice(0, 8)} · ${terminal.cols}×${terminal.rows}`}
badgeText={terminal.controllerAttachmentId ? t('controlled') : undefined}
onPress={() => openTerminal(terminal.terminalId)}
/>
))}
</Section>
</Form>
)}
</Host>
<NewTerminalSheet
isPresented={sheetOpen}
onIsPresentedChange={(open) => {
if (!open) setCreateError(null);
onSheetOpenChange(open);
}}
creating={creating}
error={createError}
onCreate={onCreate}
/>
</>
);
}
87 changes: 87 additions & 0 deletions apps/mobile/src/components/terminal/new-terminal-sheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
BottomSheet,
Button,
Form,
Host,
HStack,
Section,
Text,
TextField,
useNativeState,
} from '@expo/ui/swift-ui';
import {
autocorrectionDisabled,
disabled,
foregroundStyle,
onSubmit,
submitLabel,
textInputAutocapitalization,
} from '@expo/ui/swift-ui/modifiers';
import { useTranslations } from 'use-intl';

export function NewTerminalSheet({
isPresented,
onIsPresentedChange,
creating,
error,
onCreate,
}: {
isPresented: boolean;
onIsPresentedChange: (isPresented: boolean) => void;
creating: boolean;
error: string | null;
onCreate: (cwd: string) => Promise<boolean>;
}): React.ReactNode {
const t = useTranslations('mobile.terminals');
const cwd = useNativeState('');

const create = () => {
if (creating) return;
void onCreate(cwd.get().trim()).then((created) => {
if (created) cwd.set('');
});
};

return (
<Host style={{ position: 'absolute' }} pointerEvents="box-none">
<BottomSheet
isPresented={isPresented}
onIsPresentedChange={onIsPresentedChange}
fitToContents
>
<Form>
{error ? (
<Section>
<Text modifiers={[foregroundStyle('red')]}>{t('createError', { error })}</Text>
</Section>
) : null}

<Section title={t('newTerminal')}>
<HStack spacing={12}>
<Text>{t('cwdLabel')}</Text>
<TextField
testID="terminal-cwd-input"
text={cwd}
placeholder={t('cwdPlaceholder')}
modifiers={[
textInputAutocapitalization('never'),
autocorrectionDisabled(),
submitLabel('go'),
onSubmit(create),
]}
/>
</HStack>
</Section>

<Section>
<Button
label={creating ? t('creating') : t('create')}
onPress={create}
modifiers={[disabled(creating)]}
/>
</Section>
</Form>
</BottomSheet>
</Host>
);
}
Loading