diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx new file mode 100644 index 00000000000..15094ce1c96 --- /dev/null +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -0,0 +1,399 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + Text, + View, + type ListRenderItem +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ChevronDown, ChevronLeft, ChevronRight, File, FileText, Folder } from 'lucide-react-native' +import { useHostClient } from '../../../../src/transport/client-context' +import type { RpcSuccess } from '../../../../src/transport/types' +import { triggerError, triggerSelection } from '../../../../src/platform/haptics' +import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme' + +type MobileFileEntry = { + relativePath: string + basename: string + kind: 'text' | 'binary' +} + +type FilesListResult = { + files: MobileFileEntry[] + totalCount: number + truncated: boolean +} + +type TreeNode = { + id: string + name: string + relativePath: string + depth: number + kind: 'directory' | 'text' | 'binary' +} + +type DirectoryNode = { + name: string + relativePath: string + directories: Map + files: MobileFileEntry[] +} + +function createDirectoryNode(name: string, relativePath: string): DirectoryNode { + return { name, relativePath, directories: new Map(), files: [] } +} + +function buildTree(files: MobileFileEntry[]): DirectoryNode { + const root = createDirectoryNode('', '') + for (const file of files) { + const parts = file.relativePath.split('/').filter(Boolean) + let current = root + for (let index = 0; index < parts.length - 1; index += 1) { + const name = parts[index]! + const relativePath = parts.slice(0, index + 1).join('/') + let child = current.directories.get(name) + if (!child) { + child = createDirectoryNode(name, relativePath) + current.directories.set(name, child) + } + current = child + } + current.files.push(file) + } + return root +} + +function flattenTree(root: DirectoryNode, expanded: ReadonlySet): TreeNode[] { + const rows: TreeNode[] = [] + const visit = (directory: DirectoryNode, depth: number): void => { + const dirs = Array.from(directory.directories.values()).sort((a, b) => + a.name.localeCompare(b.name) + ) + for (const child of dirs) { + rows.push({ + id: `dir:${child.relativePath}`, + name: child.name, + relativePath: child.relativePath, + depth, + kind: 'directory' + }) + if (expanded.has(child.relativePath)) { + visit(child, depth + 1) + } + } + const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename)) + for (const file of files) { + rows.push({ + id: `file:${file.relativePath}`, + name: file.basename, + relativePath: file.relativePath, + depth, + kind: file.kind + }) + } + } + visit(root, 0) + return rows +} + +function isMarkdownPath(relativePath: string): boolean { + return /\.(md|mdx|markdown)$/i.test(relativePath) +} + +export default function MobileFileExplorerScreen() { + const { hostId, worktreeId, name } = useLocalSearchParams<{ + hostId: string + worktreeId: string + name?: string + }>() + const router = useRouter() + const { client, state: connState } = useHostClient(hostId) + const [files, setFiles] = useState([]) + const [expanded, setExpanded] = useState>(() => new Set()) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [openingPath, setOpeningPath] = useState(null) + const [truncated, setTruncated] = useState(false) + + const loadFiles = useCallback(async () => { + if (!client || connState !== 'connected') return + setLoading(true) + setError(null) + try { + const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` }) + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to load files') + } + const result = (response as RpcSuccess).result as FilesListResult + setFiles(result.files) + setTruncated(result.truncated) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to load files') + } finally { + setLoading(false) + } + }, [client, connState, worktreeId]) + + useEffect(() => { + void loadFiles() + }, [loadFiles]) + + const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files]) + + const toggleDirectory = useCallback((relativePath: string) => { + triggerSelection() + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(relativePath)) { + next.delete(relativePath) + } else { + next.add(relativePath) + } + return next + }) + }, []) + + const openFile = useCallback( + async (relativePath: string, kind: 'text' | 'binary') => { + if (!client || kind === 'binary') return + setOpeningPath(relativePath) + try { + const response = await client.sendRequest('files.open', { + worktree: `id:${worktreeId}`, + relativePath + }) + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to open file') + } + triggerSelection() + router.replace({ + pathname: '/h/[hostId]/session/[worktreeId]', + params: { hostId, worktreeId, name: name ?? '' } + }) + } catch (err) { + triggerError() + setError(err instanceof Error ? err.message : 'Unable to open file') + } finally { + setOpeningPath(null) + } + }, + [client, hostId, name, router, worktreeId] + ) + + const renderItem: ListRenderItem = ({ item }) => { + const isDirectory = item.kind === 'directory' + const isExpanded = expanded.has(item.relativePath) + const disabled = item.kind === 'binary' + const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath) + return ( + [ + styles.row, + { paddingLeft: spacing.lg + item.depth * 18 }, + pressed && !disabled && styles.rowPressed, + disabled && styles.rowDisabled + ]} + disabled={disabled || openingPath !== null} + onPress={() => { + if (isDirectory) { + toggleDirectory(item.relativePath) + } else { + void openFile(item.relativePath, item.kind) + } + }} + accessibilityLabel={isDirectory ? `Open folder ${item.name}` : `Open file ${item.name}`} + > + {isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + {isDirectory ? ( + + ) : markdown ? ( + + ) : ( + + )} + + + {item.name} + + {disabled ? Read-only on mobile : null} + + {openingPath === item.relativePath ? ( + + ) : null} + + ) + } + + return ( + + + + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => router.back()} + hitSlop={8} + accessibilityLabel="Back to session" + > + + + + + Files + + + {name || 'Active worktree'} + {truncated ? ' - Showing first 5000' : ''} + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + void loadFiles()}> + Retry + + + ) : rows.length === 0 ? ( + + No files found + + ) : ( + item.id} + contentContainerStyle={styles.listContent} + style={styles.list} + /> + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + header: { + backgroundColor: colors.bgPanel, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + topBar: { + minHeight: 58, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.md + }, + backButton: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + titleBlock: { + flex: 1, + minWidth: 0 + }, + title: { + color: colors.textPrimary, + fontSize: typography.titleSize, + fontWeight: '600' + }, + meta: { + marginTop: 2, + color: colors.textSecondary, + fontSize: typography.metaSize + }, + list: { + flex: 1 + }, + listContent: { + paddingVertical: spacing.sm + }, + row: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingRight: spacing.md + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowDisabled: { + opacity: 0.58 + }, + chevronSpacer: { + width: 16 + }, + rowTextBlock: { + flex: 1, + minWidth: 0 + }, + rowTitle: { + color: colors.textPrimary, + fontSize: typography.bodySize + }, + rowTitleDisabled: { + color: colors.textMuted + }, + rowMeta: { + marginTop: 1, + color: colors.textMuted, + fontSize: 11 + }, + state: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.md, + padding: spacing.xl + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + textAlign: 'center' + }, + retryButton: { + minHeight: 36, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.lg + }, + retryText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 02178a9f3ca..d5a9ee3da23 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -21,6 +21,8 @@ import { ArrowUp, ChevronLeft, Eraser, + Folder, + File, FileText, Monitor, Plus, @@ -79,13 +81,22 @@ type MobileSessionTab = isActive: boolean documentVersion: string } + | { + type: 'file' + id: string + title: string + filePath: string + relativePath: string + isDirty: boolean + isActive: boolean + } type SessionTabsResult = { worktree: string snapshotVersion: number tabs: MobileSessionTab[] activeTabId: string | null - activeTabType: 'terminal' | 'markdown' | null + activeTabType: 'terminal' | 'markdown' | 'file' | null } type MarkdownDocState = @@ -397,7 +408,7 @@ export default function SessionScreen() { // and may not render reliably. const webReadyHandlesRef = useRef>(new Set()) const activeHandleRef = useRef(null) - const activeSessionTabTypeRef = useRef<'terminal' | 'markdown' | null>(null) + const activeSessionTabTypeRef = useRef<'terminal' | 'markdown' | 'file' | null>(null) const pendingActiveSessionTabIdRef = useRef(null) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(new Set()) @@ -886,7 +897,7 @@ export default function SessionScreen() { activeHandleRef.current = active.terminal setActiveHandle(active.terminal) subscribeToTerminal(active.terminal) - } else if (active?.type === 'markdown') { + } else if (active) { const previous = activeHandleRef.current if (previous) { unsubscribeTerminal(previous) @@ -1399,7 +1410,7 @@ export default function SessionScreen() { triggerSelection() pendingActiveSessionTabIdRef.current = tab.id - activeSessionTabTypeRef.current = 'markdown' + activeSessionTabTypeRef.current = tab.type setActiveSessionTabId(tab.id) const prev = activeHandleRef.current if (prev) { @@ -1416,6 +1427,9 @@ export default function SessionScreen() { }) .catch(() => {}) } + if (tab.type === 'file') { + return + } const cached = markdownDocs.get(tab.id) if (cached?.status === 'ready' && cached.isDirty) { return @@ -1840,6 +1854,7 @@ export default function SessionScreen() { isActive: terminal.handle === activeHandle })) const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null + const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null const showLoadingState = connState === 'connected' && !terminalsLoaded && visibleTabs.length === 0 const showEmptyState = connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle @@ -1904,6 +1919,19 @@ export default function SessionScreen() { + [styles.filesButton, pressed && styles.filesButtonPressed]} + onPress={() => + router.push({ + pathname: '/h/[hostId]/files/[worktreeId]', + params: { hostId, worktreeId, name: worktreeName || name || '' } + }) + } + hitSlop={8} + accessibilityLabel="Open file explorer" + > + + {visibleTabs.length > 0 && ( @@ -1932,7 +1960,7 @@ export default function SessionScreen() { title: t.title, isActive: t.terminal === activeHandle }) - } else { + } else if (t.type === 'markdown') { setMarkdownActionTarget(t) } }} @@ -1942,6 +1970,9 @@ export default function SessionScreen() { {t.type === 'markdown' && ( )} + {t.type === 'file' && ( + + )} )} + ) : activeFileTab ? ( + + + + {activeFileTab.title || 'File'} + + + Opened on desktop + + ) : ( saveMobileMarkdownTab?( worktreeId: string, @@ -923,6 +946,70 @@ export class OrcaRuntimeService { return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content) } + async listMobileFiles(worktreeSelector: string): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store.getRepo(worktree.repoId) + const connectionId = repo?.connectionId ?? undefined + const files = connectionId + ? await this.listRemoteMobileFiles(worktree.path, connectionId) + : await listQuickOpenFiles(worktree.path, this.store as unknown as Store) + const entries = files + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + .slice(0, MOBILE_FILE_LIST_LIMIT) + .map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })) + + return { + worktree: worktree.id, + rootPath: worktree.path, + files: entries, + totalCount: files.length, + truncated: files.length > MOBILE_FILE_LIST_LIMIT + } + } + + async openMobileFile( + worktreeSelector: string, + relativePath: string + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + const kind = isMobileBinaryPath(relativePath) + ? 'binary' + : isMobileMarkdownPath(relativePath) + ? 'markdown' + : 'text' + if (kind === 'binary') { + return { worktree: worktree.id, relativePath, kind, opened: false } + } + if (!this.notifier?.openFile) { + throw new Error('renderer_unavailable') + } + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + this.notifier.openFile(worktree.id, filePath, relativePath) + return { worktree: worktree.id, relativePath, kind, opened: true } + } + + private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return [] + } + return provider.listFiles(rootPath) + } + onMobileSessionTabsChanged( listener: (snapshot: RuntimeMobileSessionTabsResult) => void ): () => void { @@ -4995,7 +5082,7 @@ export class OrcaRuntimeService { ): RuntimeMobileSessionTabsResult { const tabs: RuntimeMobileSessionClientTab[] = [] for (const tab of snapshot.tabs) { - if (tab.type === 'markdown') { + if (tab.type === 'markdown' || tab.type === 'file') { tabs.push(tab) continue } @@ -7240,6 +7327,40 @@ function maxTimestamp(left: number | null, right: number | null): number | null return Math.max(left, right) } +function isSafeMobileRelativePath(relativePath: string): boolean { + if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { + return false + } + const parts = relativePath.replace(/\\/g, '/').split('/') + return parts.every((part) => part !== '' && part !== '.' && part !== '..') +} + +function isMobileMarkdownPath(relativePath: string): boolean { + return /\.(md|mdx|markdown)$/i.test(relativePath) +} + +function isMobileBinaryPath(relativePath: string): boolean { + const basename = basenameFromRelativePath(relativePath) + const dotIndex = basename.lastIndexOf('.') + if (dotIndex <= 0) { + return false + } + return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) +} + +function basenameFromRelativePath(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, '/') + return normalized.slice(normalized.lastIndexOf('/') + 1) +} + +function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { + const normalizedRelativePath = relativePath.replace(/\\/g, '/') + if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { + return win32.join(rootPath, ...normalizedRelativePath.split('/')) + } + return posix.join(rootPath, ...normalizedRelativePath.split('/')) +} + function compareWorktreePs( left: RuntimeWorktreePsSummary, right: RuntimeWorktreePsSummary diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts new file mode 100644 index 00000000000..4820b6ba5de --- /dev/null +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('file RPC methods', () => { + it('lists files for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listMobileFiles: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + rootPath: '/repo', + files: [], + totalCount: 0, + truncated: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('files.list', { worktree: 'id:wt-1' })) + + expect(runtime.listMobileFiles).toHaveBeenCalledWith('id:wt-1') + expect(response).toMatchObject({ + ok: true, + result: { worktree: 'wt-1', files: [] } + }) + }) + + it('opens a relative file path for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + openMobileFile: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + relativePath: 'docs/readme.md', + kind: 'markdown', + opened: true + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'docs/readme.md' }) + ) + + expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md') + expect(response).toMatchObject({ + ok: true, + result: { kind: 'markdown', opened: true } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts new file mode 100644 index 00000000000..613446ddac1 --- /dev/null +++ b/src/main/runtime/rpc/methods/files.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' + +const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +const FileOpen = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')) +}) + +export const FILE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'files.list', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.listMobileFiles(params.worktree) + }), + defineMethod({ + name: 'files.open', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.openMobileFile(params.worktree, params.relativePath) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 5461002440b..f312491ab7d 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -11,6 +11,7 @@ import { STATS_METHODS } from './stats' import { ACCOUNT_METHODS } from './accounts' import { COMPUTER_METHODS } from './computer' import { SESSION_TAB_METHODS } from './session-tabs' +import { FILE_METHODS } from './files' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -27,5 +28,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATS_METHODS, ...ACCOUNT_METHODS, ...COMPUTER_METHODS, - ...SESSION_TAB_METHODS + ...SESSION_TAB_METHODS, + ...FILE_METHODS ] diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 76ab7089095..173be7653b8 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -276,6 +276,8 @@ function registerRuntimeWindowLifecycle( renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), focusTerminal: (tabId, worktreeId) => send('ui:focusTerminal', { tabId, worktreeId }), focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }), + openFile: (worktreeId, filePath, relativePath) => + send('ui:openFileFromMobile', { worktreeId, filePath, relativePath }), readMobileMarkdownTab: (worktreeId, tabId) => requestMobileMarkdownFromRenderer(mainWindow, { operation: 'read', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 840f4aad12b..c5d288551ee 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1127,6 +1127,9 @@ export type PreloadApi = { onFocusEditorTab: ( callback: (data: { tabId: string; worktreeId: string }) => void ) => () => void + onOpenFileFromMobile: ( + callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void + ) => () => void onMobileMarkdownRequest: ( callback: (request: RuntimeMobileMarkdownRequest) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 5ea8c1900f7..caa79f05307 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1946,6 +1946,16 @@ const api = { ipcRenderer.on('ui:focusEditorTab', listener) return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) }, + onOpenFileFromMobile: ( + callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; filePath: string; relativePath: string } + ) => callback(data) + ipcRenderer.on('ui:openFileFromMobile', listener) + return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) + }, onMobileMarkdownRequest: ( callback: (request: RuntimeMobileMarkdownRequest) => void ): (() => void) => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b89840e9146..f513711d0a8 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -29,6 +29,7 @@ import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit- import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' +import { detectLanguage } from '@/lib/language-detect' export { resolveZoomTarget } from './resolve-zoom-target' @@ -369,6 +370,28 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onOpenFileFromMobile(({ worktreeId, filePath, relativePath }) => { + const store = useAppStore.getState() + const basename = relativePath.split(/[\\/]/).pop() || relativePath + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + // Why: mobile only sends a desktop-backed path. The renderer owns + // editor tab creation so grouped tab order and markdown bridges update + // through the same store path as desktop File Explorer. + store.openFile({ + filePath, + relativePath, + worktreeId, + language: detectLanguage(basename), + mode: 'edit' + }) + store.setActiveTabType('editor') + store.revealWorktreeInSidebar(worktreeId) + }) + ) + unsubs.push( window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => { if (paneRuntimeId != null) { diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 7d14f4d21af..f330f92fd43 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -3,6 +3,7 @@ import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/termina import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { AppState } from '@/store/types' import type { + RuntimeMobileSessionFileTab, RuntimeMobileSessionMarkdownTab, RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTabsSnapshot, @@ -213,6 +214,8 @@ function buildMobileSessionTabSnapshots(state: AppState): RuntimeMobileSessionTa const markdown = file ? buildMobileMarkdownTab(state, file.id, item.tabId) : null if (markdown) { tabs.push(markdown) + } else if (file) { + tabs.push(buildMobileFileTab(state, file.id, item.tabId)) } } } @@ -277,6 +280,30 @@ function buildMobileMarkdownTab( } } +function buildMobileFileTab( + state: AppState, + fileId: string, + unifiedTabId?: string +): RuntimeMobileSessionFileTab { + const file = state.openFiles.find((candidate) => candidate.id === fileId)! + const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File' + + return { + type: 'file', + id: unifiedTabId ?? file.id, + title, + filePath: file.filePath, + relativePath: file.relativePath, + language: file.language, + isDirty: file.isDirty, + isActive: unifiedTabId + ? state.groupsByWorktree[file.worktreeId]?.some( + (group) => group.activeTabId === unifiedTabId + ) === true + : state.activeFileId === file.id + } +} + function stableHashString(value: string): string { let hash = 2166136261 for (let i = 0; i < value.length; i += 1) { diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index c414da52a09..4f212e1b0c5 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -94,9 +94,21 @@ export type RuntimeMobileSessionMarkdownTab = { documentVersion: string } +export type RuntimeMobileSessionFileTab = { + type: 'file' + id: string + title: string + filePath: string + relativePath: string + language: string + isDirty: boolean + isActive: boolean +} + export type RuntimeMobileSessionSnapshotTab = | RuntimeMobileSessionTerminalTab | RuntimeMobileSessionMarkdownTab + | RuntimeMobileSessionFileTab export type RuntimeMobileSessionTerminalClientTab = Omit< RuntimeMobileSessionTerminalTab, @@ -108,13 +120,14 @@ export type RuntimeMobileSessionTerminalClientTab = Omit< export type RuntimeMobileSessionClientTab = | RuntimeMobileSessionTerminalClientTab | RuntimeMobileSessionMarkdownTab + | RuntimeMobileSessionFileTab export type RuntimeMobileSessionTabsSnapshot = { worktree: string snapshotVersion: number activeGroupId: string | null activeTabId: string | null - activeTabType: 'terminal' | 'markdown' | null + activeTabType: 'terminal' | 'markdown' | 'file' | null tabs: RuntimeMobileSessionSnapshotTab[] } @@ -123,10 +136,31 @@ export type RuntimeMobileSessionTabsResult = { snapshotVersion: number activeGroupId: string | null activeTabId: string | null - activeTabType: 'terminal' | 'markdown' | null + activeTabType: 'terminal' | 'markdown' | 'file' | null tabs: RuntimeMobileSessionClientTab[] } +export type RuntimeFileListEntry = { + relativePath: string + basename: string + kind: 'text' | 'binary' +} + +export type RuntimeFileListResult = { + worktree: string + rootPath: string + files: RuntimeFileListEntry[] + totalCount: number + truncated: boolean +} + +export type RuntimeFileOpenResult = { + worktree: string + relativePath: string + kind: 'markdown' | 'text' | 'binary' + opened: boolean +} + export type RuntimeTerminalSummary = { handle: string worktreeId: string