diff --git a/.gitignore b/.gitignore index 05ea89b..6ed5500 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ Thumbs.db # Toolset builder scratch /.work/ /.out/ +/tools/.work/ +/tools/.out/ # Kotlin build session dir .kotlin/ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index db9245f..e8f8bc3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt b/app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt index ffd9cd0..390c3c1 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt @@ -5,6 +5,8 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -21,17 +23,25 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -39,11 +49,15 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import io.github.oustery.opencodephone.core.designsystem.OcChipTone +import io.github.oustery.opencodephone.core.designsystem.OcStatusChip import io.github.oustery.opencodephone.core.linuxenv.ServerState +import io.github.oustery.opencodephone.core.model.AgentActivity import io.github.oustery.opencodephone.core.model.BootstrapPhase import io.github.oustery.opencodephone.core.settings.SetupChoice import io.github.oustery.opencodephone.ui.bootstrap.BootstrapScreen @@ -51,6 +65,7 @@ import io.github.oustery.opencodephone.ui.chat.ChatScreen import io.github.oustery.opencodephone.ui.files.FilesScreen import io.github.oustery.opencodephone.ui.settings.SettingsScreen import io.github.oustery.opencodephone.ui.terminal.TerminalScreen +import kotlinx.coroutines.launch object Routes { const val BOOTSTRAP = "bootstrap" @@ -60,6 +75,13 @@ object Routes { const val SETTINGS = "settings" } +/** + * Drawer handle published to every [ScreenHeader]: screens render the menu + * button when it is non-null (phones) and skip it on wide layouts where the + * navigation rail is permanently visible. + */ +val LocalMainDrawer = staticCompositionLocalOf { null } + @Composable fun AppRoot(graph: AppGraph) { val navController = rememberNavController() @@ -91,10 +113,14 @@ fun AppRoot(graph: AppGraph) { } // Autostart is owned by AppGraph (independent of navigation). Keep - // setup and server startup foregrounded, but never stop the service in - // the READY -> Starting transition. - LaunchedEffect(bootstrapState.phase, serverState, serverConfiguration.mode, setupChoice) { + // setup and server startup foregrounded, and never stop the service in + // the READY -> Starting transition. A running agent task keeps the + // service up in every mode (also remote-only), so backgrounding the app + // cannot freeze the work in progress. + val agentActivity by graph.agentActivity.collectAsStateWithLifecycle() + LaunchedEffect(bootstrapState.phase, serverState, serverConfiguration.mode, setupChoice, agentActivity) { when { + agentActivity == AgentActivity.RUNNING -> EnvService.start(context) bootstrapState.phase == BootstrapPhase.READY && serverConfiguration.mode == io.github.oustery.opencodephone.core.settings.ServerMode.REMOTE -> EnvService.stop(context) serverState is ServerState.Starting || serverState is ServerState.Running -> EnvService.start(context) @@ -108,6 +134,8 @@ fun AppRoot(graph: AppGraph) { BoxWithConstraints(modifier = Modifier.fillMaxSize().imePadding()) { val isWide = maxWidth >= 600.dp if (isWide) { + // Wide layouts keep a permanent sidebar: the rail is always there, + // so the header menu button is not needed. Row(modifier = Modifier.fillMaxSize()) { AppNavRail(navController) Surface(modifier = Modifier.weight(1f)) { @@ -115,11 +143,36 @@ fun AppRoot(graph: AppGraph) { } } } else { - Column(modifier = Modifier.fillMaxSize()) { - Surface(modifier = Modifier.weight(1f)) { - AppNavHost(navController, graph) + // Phones: sidebar navigation. The drawer hosts the destinations, + // the live server status and the agent task board; the bottom bar + // is gone in favour of one consistent nav surface. + val drawerState = rememberDrawerState(DrawerValue.Closed) + val drawerScope = rememberCoroutineScope() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val drawerAvailable = currentRoute != Routes.BOOTSTRAP + androidx.compose.runtime.CompositionLocalProvider( + LocalMainDrawer provides drawerState.takeIf { drawerAvailable }, + ) { + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerAvailable, + drawerContent = { + if (drawerAvailable) { + AppDrawerContent( + navController = navController, + currentRoute = currentRoute, + serverState = serverState, + agentActivity = agentActivity, + closeDrawer = { drawerScope.launch { drawerState.close() } }, + ) + } + }, + ) { + Surface(modifier = Modifier.fillMaxSize()) { + AppNavHost(navController, graph) + } } - AppNavBar(navController) } } } @@ -170,17 +223,66 @@ private val destinations = listOf( ) @Composable -private fun AppNavBar(navController: androidx.navigation.NavHostController) { - val backStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = backStackEntry?.destination?.route - NavigationBar { - destinations.forEach { dest -> - NavigationBarItem( - selected = currentRoute == dest.route, - onClick = { navigateTo(navController, dest.route) }, - icon = { Icon(painterResource(dest.iconRes), contentDescription = null) }, - label = { Text(stringResource(dest.labelRes)) }, - ) +private fun AppDrawerContent( + navController: NavHostController, + currentRoute: String?, + serverState: ServerState, + agentActivity: AgentActivity, + closeDrawer: () -> Unit, +) { + ModalDrawerSheet { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(vertical = 12.dp), + ) { + // Brand block with the live state of the machine: server line and + // agent task board, so the drawer doubles as a status glance. + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + OcStatusChip( + text = when (serverState) { + is ServerState.Running -> stringResource(R.string.env_service_running, serverState.port) + is ServerState.Starting -> stringResource(R.string.env_service_starting) + is ServerState.Failed -> stringResource(R.string.env_service_failed, serverState.message.take(60)) + ServerState.Stopped -> stringResource(R.string.env_service_stopped) + }, + tone = when (serverState) { + is ServerState.Running -> OcChipTone.PRIMARY + is ServerState.Failed -> OcChipTone.ERROR + else -> OcChipTone.NEUTRAL + }, + ) + OcStatusChip( + text = if (agentActivity == AgentActivity.RUNNING) { + stringResource(R.string.env_service_task_running) + } else { + stringResource(R.string.agent_status_idle) + }, + tone = if (agentActivity == AgentActivity.RUNNING) OcChipTone.TERTIARY else OcChipTone.NEUTRAL, + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + destinations.forEach { dest -> + NavigationDrawerItem( + label = { Text(stringResource(dest.labelRes)) }, + icon = { Icon(painterResource(dest.iconRes), contentDescription = null) }, + selected = currentRoute == dest.route, + onClick = { + closeDrawer() + navigateTo(navController, dest.route) + }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), + ) + } } } } @@ -221,15 +323,21 @@ fun ScreenHeader( modifier = modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface) .statusBarsPadding().padding(horizontal = 20.dp, vertical = 12.dp), ) { + // Leading menu button whenever a modal drawer is available (phones). + val menu: @Composable RowScope.() -> Unit = { NavMenuButton() } if (maxWidth < 480.dp) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(title, style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.semantics { heading() }) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + menu() + Text(title, style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }) + } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions) } } else { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + menu() Text(title, style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).semantics { heading() }) Row(modifier = Modifier.weight(2f), verticalAlignment = Alignment.CenterVertically, @@ -239,6 +347,24 @@ fun ScreenHeader( } } +/** + * The hamburger that opens the main drawer. Renders nothing on wide layouts + * (the rail is permanently visible) and during bootstrap (no destinations). + */ +@Composable +fun NavMenuButton() { + val drawer = LocalMainDrawer.current + val scope = rememberCoroutineScope() + if (drawer != null) { + IconButton(onClick = { scope.launch { drawer.open() } }) { + Icon( + painterResource(R.drawable.ic_menu), + contentDescription = stringResource(R.string.nav_open_menu), + ) + } + } +} + /** Content container that avoids the bottom navigation bar overlap. */ @Composable fun ScreenBody(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { diff --git a/app/src/main/java/io/github/oustery/opencodephone/EnvService.kt b/app/src/main/java/io/github/oustery/opencodephone/EnvService.kt index 38a640f..fbe709e 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/EnvService.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/EnvService.kt @@ -3,14 +3,18 @@ package io.github.oustery.opencodephone import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import io.github.oustery.opencodephone.core.linuxenv.ServerState +import io.github.oustery.opencodephone.core.model.AgentActivity import io.github.oustery.opencodephone.core.model.BootstrapPhase +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -19,11 +23,27 @@ import kotlinx.coroutines.launch * Keeps the `opencode serve` child process alive while the user works outside * the app. Without it, backgrounding the app would let the OS reclaim the * Linux environment process. + * + * The notification doubles as the task board: the shared [AgentActivity] + * state renders "running / done" so a prompt keeps its status visible after + * the user leaves the chat, and a partial wake lock keeps the streaming run + * (and the shell behind it) from being frozen while the screen is off. */ class EnvService : LifecycleService() { private lateinit var graph: AppGraph + /** Latest environment/bootstrap line; shown when no task note wins. */ + private var baseText: String? = null + + /** Transient agent-task line ("running…", "done") that wins over base. */ + private var taskText: String? = null + + /** Mirrors [AgentActivity.RUNNING] for the indeterminate progress bar. */ + private var taskRunning = false + + private var wakeLock: PowerManager.WakeLock? = null + override fun onCreate() { super.onCreate() createChannel() @@ -42,32 +62,76 @@ class EnvService : LifecycleService() { private fun observeGraph() { lifecycleScope.launch { graph.runtime.serverState.collectLatest { state -> - updateNotification(when (state) { + baseText = when (state) { is ServerState.Running -> getString(R.string.env_service_running, state.port) is ServerState.Starting -> getString(R.string.env_service_starting) is ServerState.Failed -> getString(R.string.env_service_failed, state.message.take(80)) ServerState.Stopped -> getString(R.string.env_service_stopped) - }) + } + render() } } // During setup the service keeps the app at foreground importance so // Android is less likely to kill the bootstrap child processes. lifecycleScope.launch { graph.bootstrap.state.collectLatest { state -> - when (state.phase) { + baseText = when (state.phase) { BootstrapPhase.READY -> if (graph.runtime.serverState.value is ServerState.Stopped) { - updateNotification(getString(R.string.env_service_stopped)) - } + getString(R.string.env_service_stopped) + } else baseText BootstrapPhase.FAILED -> - updateNotification(getString(R.string.env_service_failed, state.error?.take(80).orEmpty())) - else -> - updateNotification(state.label.ifEmpty { getString(R.string.bootstrap_preparing) }) + getString(R.string.env_service_failed, state.error?.take(80).orEmpty()) + else -> state.label.ifEmpty { getString(R.string.bootstrap_preparing) } + } + render() + } + } + // Agent task board: while a prompt runs, hold a wake lock (bounded) + // and show the running state; on completion flash "done" briefly and + // fall back to the environment line. + lifecycleScope.launch { + graph.agentActivity.collectLatest { activity -> + when (activity) { + AgentActivity.RUNNING -> { + acquireWakeLock() + taskRunning = true + taskText = getString(R.string.env_service_task_running) + render() + } + AgentActivity.IDLE -> { + releaseWakeLock() + taskRunning = false + if (taskText == null) return@collectLatest + taskText = getString(R.string.env_service_task_done) + render() + delay(DONE_FLASH_MS) + if (taskText == getString(R.string.env_service_task_done)) { + taskText = null + render() + } + } } } } } + private fun render() { + updateNotification(taskText ?: baseText ?: getString(R.string.env_service_starting)) + } + + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + wakeLock = (getSystemService(POWER_SERVICE) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG) + .also { it.acquire(WAKELOCK_TIMEOUT_MS) } + } + + private fun releaseWakeLock() { + wakeLock?.let { lock -> if (lock.isHeld) lock.release() } + wakeLock = null + } + private fun updateNotification(text: String) { val nm = getSystemService(NotificationManager::class.java) nm.notify(NOTIFICATION_ID, buildNotification(text)) @@ -78,6 +142,11 @@ class EnvService : LifecycleService() { return if ((application as OpenCodePhoneApp).startup.value is AppStartup.Failed) START_NOT_STICKY else START_STICKY } + override fun onDestroy() { + releaseWakeLock() + super.onDestroy() + } + // Child lifetime is owned by AppGraph/ManagedServer, not notification // teardown. A delayed stopService from setup can overlap the next // startForegroundService; onDestroy must not kill that new child. @@ -91,17 +160,33 @@ class EnvService : LifecycleService() { getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } - private fun buildNotification(text: String): Notification = - NotificationCompat.Builder(this, CHANNEL_ID) + private fun buildNotification(text: String): Notification { + val openApp = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val running = taskRunning + return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_prompt) .setContentTitle(getString(R.string.env_service_title)) .setContentText(text) + .setContentIntent(openApp) .setOngoing(true) + .setOnlyAlertOnce(true) + .setProgress(0, 0, running) .build() + } companion object { private const val CHANNEL_ID = "opencodephone_env" private const val NOTIFICATION_ID = 1001 + private const val WAKELOCK_TAG = "opencodephone:agentTask" + + /** A run must never hold the CPU forever if an event is missed. */ + private const val WAKELOCK_TIMEOUT_MS = 30 * 60 * 1000L + private const val DONE_FLASH_MS = 6_000L fun start(context: Context) { val intent = Intent(context, EnvService::class.java) diff --git a/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt b/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt index d985386..bb03529 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt @@ -5,6 +5,7 @@ import android.content.Context import io.github.oustery.opencodephone.core.linuxenv.BootstrapManager import io.github.oustery.opencodephone.core.linuxenv.EnvLayout import io.github.oustery.opencodephone.core.linuxenv.EnvironmentRuntime +import io.github.oustery.opencodephone.core.model.AgentActivity import io.github.oustery.opencodephone.core.opencode.ConnectionConfig import io.github.oustery.opencodephone.core.opencode.OpenCodeClient import io.github.oustery.opencodephone.core.opencode.OpenCodeConsoleClient @@ -89,6 +90,16 @@ class AppGraph(context: Application) { _selectedProject.value = relativePath } + // Shared agent-task status for the persistent notification and the + // sidebar footer: "the task is running" survives leaving the chat screen, + // so the user sees completion outside the app as well. + private val _agentActivity = MutableStateFlow(AgentActivity.IDLE) + val agentActivity: StateFlow = _agentActivity + + fun setAgentActivity(activity: AgentActivity) { + if (_agentActivity.value != activity) _agentActivity.value = activity + } + init { appScope.launch { observeServerLifecycle( diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatViewModel.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatViewModel.kt index 01abd6e..67f557b 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatViewModel.kt @@ -35,6 +35,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonArray @@ -137,6 +139,17 @@ class ChatViewModel(private val graph: AppGraph) : ViewModel() { private var chatDraftJob: Job? = null init { + // Publish the agent task status to the app shell (notification + + // sidebar): every busy transition of the active session, from any of + // its sources (prompt sent, session.status events, disconnects). + viewModelScope.launch { + _state.map { it.busy }.distinctUntilChanged().collect { busy -> + graph.setAgentActivity( + if (busy) io.github.oustery.opencodephone.core.model.AgentActivity.RUNNING + else io.github.oustery.opencodephone.core.model.AgentActivity.IDLE, + ) + } + } // Restore unsaved work after process death; attachment copies live in // the workspace, so entries whose file is gone are silently dropped. viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FileTree.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FileTree.kt new file mode 100644 index 0000000..7638783 --- /dev/null +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FileTree.kt @@ -0,0 +1,82 @@ +package io.github.oustery.opencodephone.ui.files + +import java.io.File +import java.util.Locale + +/** + * One row of the file explorer tree. [depth] is the visual nesting level of + * the flattened view; [size] is the file length in bytes for files (-1 for + * directories); [childCount] is the number of visible entries a directory + * carries once it has been loaded (-1 while unknown). + */ +data class FileEntry( + val file: File, + val name: String, + val isDir: Boolean, + val depth: Int, + val size: Long = -1L, + val childCount: Int = -1, +) + +/** + * Pure helpers of the lazy file tree (no Android imports, JVM-testable). + * + * The explorer loads one directory level at a time: [flatten] turns the + * loaded-children map plus the expanded set into the visible row list. + * Directories that are expanded but not loaded yet simply show no children + * (the row itself signals the pending load), and expanding a directory never + * rescans the rest of the tree — that global rescan was the old expansion bug. + */ +object FileTree { + + /** + * Depth-first flattening. [children] maps a directory path to its direct + * visible children (already sorted); [root] is the workspace itself. + * The returned entries carry their visual [FileEntry.depth]. + */ + fun flatten( + children: Map>, + expanded: Set, + root: String, + ): List { + val rows = mutableListOf() + fun emit(path: String, depth: Int) { + val kids = children[path] ?: return + for (child in kids) { + val positioned = child.copy(depth = depth) + rows += positioned + if (positioned.isDir && positioned.file.path in expanded) { + emit(positioned.file.path, depth + 1) + } + } + } + emit(root, 0) + return rows + } + + /** Sort: directories first, then case-insensitive by name. */ + fun sortEntries(entries: List): List = + entries.sortedWith( + compareByDescending { it.isDir } + .thenBy { it.name.lowercase(Locale.ROOT) } + .thenBy { it.name }, + ) + + /** Human-readable file size (B / KB / MB / GB), for the row tail. */ + fun formatSize(bytes: Long): String { + if (bytes < 0) return "" + if (bytes < 1024) return "$bytes B" + val units = listOf("KB", "MB", "GB", "TB") + var value = bytes / 1024.0 + var unit = 0 + while (value >= 1024.0 && unit < units.lastIndex) { + value /= 1024.0 + unit++ + } + return if (value >= 100) "%d %s".format(Locale.ROOT, value.toLong(), units[unit]) + else "%.1f %s".format(Locale.ROOT, value, units[unit]) + } + + /** True when a name is a dotfile (hidden by default in the explorer). */ + fun isHiddenName(name: String): Boolean = name.startsWith(".") && name != "." && name != ".." +} diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesScreen.kt index 58f1d1f..fb26be8 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesScreen.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesScreen.kt @@ -2,6 +2,7 @@ package io.github.oustery.opencodephone.ui.files import androidx.compose.foundation.background import androidx.compose.foundation.Image +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.ExperimentalFoundationApi @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.draw.rotate import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription @@ -33,6 +35,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -92,6 +95,16 @@ fun FilesScreen(graph: AppGraph) { ScreenHeader( title = stringResource(R.string.tab_files), actions = { + IconButton(onClick = { vm.toggleHidden() }) { + Icon( + painterResource(R.drawable.ic_eye), + contentDescription = stringResource( + if (state.hiddenVisible) R.string.files_hide_hidden else R.string.files_show_hidden, + ), + tint = if (state.hiddenVisible) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } IconButton(onClick = { vm.showNewFile(true) }) { Icon(painterResource(R.drawable.ic_add), contentDescription = stringResource(R.string.files_new_file)) } @@ -135,7 +148,7 @@ fun FilesScreen(graph: AppGraph) { } } - if (state.workspaceEmpty && state.busy == null) { + if (state.workspaceLoaded && state.workspaceEmpty && state.busy == null) { OcEmptyState( title = stringResource(R.string.files_empty_title), description = stringResource(R.string.files_empty_body), @@ -144,10 +157,10 @@ fun FilesScreen(graph: AppGraph) { actionText = stringResource(R.string.files_clone), onAction = { vm.showClone(true) }, ) - } else if (state.entries.isEmpty() && state.busy == null) { - // The first scan has not reported yet (workspaceEmpty is only - // decided after it completes): placeholder rows instead of a - // blank screen (M3E audit E-017). + } else if (!state.workspaceLoaded && state.busy == null) { + // The first root listing has not landed yet (workspaceLoaded + // is only set after it completes): placeholder rows instead of + // a blank screen (M3E audit E-017). Column( modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -161,10 +174,12 @@ fun FilesScreen(graph: AppGraph) { } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { - items(state.entries, key = { it.file.absolutePath }) { entry -> + items(state.rows, key = { it.file.absolutePath }) { entry -> FileRow( entry, expanded = entry.file.absolutePath in state.expanded, + loading = entry.file.absolutePath in state.loadingDirs, + error = state.dirErrors[entry.file.absolutePath], onClick = { if (entry.isDir) vm.toggleDir(entry) else vm.openFile(entry) }, onLongClick = { actionsFor = entry }, ) @@ -328,9 +343,27 @@ private fun GitBar(branch: String, changes: Int, busy: Boolean, onOpenCommit: () @OptIn(ExperimentalFoundationApi::class) @Composable -private fun FileRow(entry: FileEntry, expanded: Boolean, onClick: () -> Unit, onLongClick: () -> Unit) { +private fun FileRow( + entry: FileEntry, + expanded: Boolean, + loading: Boolean, + error: String?, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { val description = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed) val actionHint = stringResource(R.string.files_row_actions) + // The chevron answers the tap synchronously: expansion state flips in + // the view model before any disk work starts. + val chevronRotation by animateFloatAsState( + targetValue = if (expanded) 90f else 0f, + label = "folderChevron", + ) + val tail = when { + entry.isDir && entry.childCount >= 0 -> stringResource(R.string.files_child_count, entry.childCount) + !entry.isDir -> FileTree.formatSize(entry.size) + else -> "" + } Row( modifier = Modifier .fillMaxWidth().heightIn(min = 48.dp) @@ -346,12 +379,20 @@ private fun FileRow(entry: FileEntry, expanded: Boolean, onClick: () -> Unit, on horizontalArrangement = Arrangement.spacedBy(8.dp), ) { if (entry.isDir) { - Icon( - painterResource(R.drawable.ic_chevron_right), - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + modifier = Modifier.size(18.dp).rotate(chevronRotation), + tint = if (error != null) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Icon( painterResource(R.drawable.ic_tab_files), contentDescription = null, @@ -373,6 +414,13 @@ private fun FileRow(entry: FileEntry, expanded: Boolean, onClick: () -> Unit, on style = MaterialTheme.typography.bodyLarge, color = if (entry.isDir) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, ) + if (tail.isNotEmpty()) { + Text( + text = tail, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt index 70c0aab..c357cea 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt @@ -3,6 +3,7 @@ package io.github.oustery.opencodephone.ui.files import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.oustery.opencodephone.AppGraph +import io.github.oustery.opencodephone.R import io.github.oustery.opencodephone.core.linuxenv.* import io.github.oustery.opencodephone.core.settings.DraftStore import kotlinx.coroutines.* @@ -12,14 +13,25 @@ import kotlinx.coroutines.sync.withLock import java.io.File import java.util.concurrent.atomic.AtomicLong - data class FileEntry(val file: File, val name: String, val isDir: Boolean, val depth: Int) - -/** One entry of `git status --porcelain -z` (staged+unstaged combined). */ data class GitChange(val status: String, val path: String) data class FilesUiState( - val entries: List = emptyList(), val expanded: Set = emptySet(), - val workspaceEmpty: Boolean = false, val gitBranch: String? = null, val gitChanges: Int = 0, + /** Direct children per loaded directory path (sorted, filtered). */ + val children: Map> = emptyMap(), + /** Flattened visible rows derived from [children] + [expanded]. */ + val rows: List = emptyList(), + val expanded: Set = emptySet(), + /** Directories whose single-level load is still in flight. */ + val loadingDirs: Set = emptySet(), + /** Per-directory load errors; tapping the directory retries. */ + val dirErrors: Map = emptyMap(), + /** Dotfiles (`.git`, `.env`, …) stay hidden until the user asks. */ + val hiddenVisible: Boolean = false, + /** False until the first root listing lands (skeleton phase). */ + val workspaceLoaded: Boolean = false, + /** True once loaded and the visible tree is empty (empty-state CTA). */ + val workspaceEmpty: Boolean = false, + val gitBranch: String? = null, val gitChanges: Int = 0, val changeList: List = emptyList(), val gitProject: String? = null, val editing: EditingFile? = null, val showCloneDialog: Boolean = false, val showNewFileDialog: Boolean = false, @@ -37,7 +49,6 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { private val files = WorkspaceFiles(workspace) private val drafts = DraftStore(java.io.File(graph.appContext.filesDir, "drafts")) private val gitLock = Mutex() - private val scanEpoch = AtomicLong() private val editEpoch = AtomicLong() private var refreshJob: Job? = null private var editorDraftJob: Job? = null @@ -50,6 +61,95 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { restoreEditorDraft() } + // ------------------------------------------------------------------ tree + + /** Builds rows from the loaded tree; the single source of row truth. */ + private fun rebuild(s: FilesUiState): FilesUiState { + val rows = FileTree.flatten(s.children, s.expanded, workspace.absolutePath) + return s.copy( + rows = rows, + workspaceEmpty = s.workspaceLoaded && rows.isEmpty(), + ) + } + + /** + * Lists one directory level: visible (non-hidden unless requested) + * entries, directories first, each annotated with size/child count. + * A directory load failure only marks that directory — the rest of the + * tree keeps working (the old global scan died on the first bad entry). + */ + private fun listChildren(dir: File, hiddenVisible: Boolean): List { + files.checked(dir) + val all = dir.listFiles() + ?: throw IllegalStateException(graph.appContext.getString(R.string.files_folder_unreadable, dir.name)) + val visible = all + .filter { hiddenVisible || !FileTree.isHiddenName(it.name) } + .filter { !java.nio.file.Files.isSymbolicLink(it.toPath()) } + val entries = visible.map { child -> + if (child.isDirectory) { + val count = child.listFiles() + ?.count { hiddenVisible || !FileTree.isHiddenName(it.name) } ?: -1 + FileEntry(child, child.name, true, 0, childCount = count) + } else { + FileEntry(child, child.name, false, 0, size = child.length()) + } + } + return FileTree.sortEntries(entries) + } + + /** Merges a freshly listed directory into the tree state. */ + private fun withLoadedDir(path: String, list: List?) { + _state.update { s -> + val kids = if (list == null) s.children - path else s.children + (path to list) + rebuild( + s.copy( + children = kids, + loadingDirs = s.loadingDirs - path, + dirErrors = if (list == null) s.dirErrors else s.dirErrors - path, + workspaceLoaded = if (path == workspace.absolutePath) true else s.workspaceLoaded, + ), + ) + } + } + + /** Loads a single directory level without touching the rest of the tree. */ + private fun loadChildren(path: String) { + _state.update { it.copy(loadingDirs = it.loadingDirs + path, dirErrors = it.dirErrors - path) } + viewModelScope.launch(Dispatchers.IO) { + val hidden = _state.value.hiddenVisible + val list = try { + listChildren(File(path), hidden) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { s -> + if (path in s.loadingDirs) { + s.copy( + loadingDirs = s.loadingDirs - path, + dirErrors = s.dirErrors + (path to (e.message ?: "Load failed")), + ) + } else s + } + return@launch + } + withLoadedDir(path, list) + } + } + + fun toggleHidden() { + val nowVisible = !_state.value.hiddenVisible + _state.update { s -> rebuild(s.copy(hiddenVisible = nowVisible)) } + // Hidden entries change what each loaded directory shows: reload + // everything already on screen (bounded by what the user expanded). + val targets = _state.value.children.keys.toList() + viewModelScope.launch(Dispatchers.IO) { + for (path in targets) { + val list = runCatching { listChildren(File(path), nowVisible) }.getOrNull() ?: continue + withLoadedDir(path, list) + } + } + } + /** * Restore the unsaved editor buffer after process death (F-003). The * snapshot is reopened fresh so the save-time external-change check @@ -108,26 +208,29 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { } fun refresh() { - val ticket = scanEpoch.incrementAndGet() - val expanded = _state.value.expanded refreshJob?.cancel() refreshJob = viewModelScope.launch(Dispatchers.IO) { try { - val entries = mutableListOf() - fun scan(dir: File, depth: Int) { - ensureActive() - if (depth > 64) return - val children = dir.listFiles()?.sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name }) ?: return - for (child in children) { - ensureActive() - if (java.nio.file.Files.isSymbolicLink(child.toPath())) continue - files.checked(child) - entries += FileEntry(child, child.name, child.isDirectory, depth) - if (child.isDirectory && child.absolutePath in expanded) scan(child, depth + 1) + // Reload the workspace root plus every loaded directory that + // is still expanded; expansion state survives the refresh. + val loaded = _state.value.children.keys + val targets = (loaded + workspace.absolutePath) + .filter { it == workspace.absolutePath || it in _state.value.expanded } + for (path in targets) { + val list = runCatching { listChildren(File(path), _state.value.hiddenVisible) }.getOrNull() + if (list != null) { + withLoadedDir(path, list) + } else if (path == workspace.absolutePath && _state.value.children[path] == null) { + // First root listing failed (root is app-private, so + // this is rare): finish the skeleton phase instead of + // spinning forever, with the empty-state CTA. + withLoadedDir(path, emptyList()) } + // Otherwise keep the previous listing of that directory. + } + if (!_state.value.workspaceLoaded) { + withLoadedDir(workspace.absolutePath, emptyList()) } - scan(workspace, 0) - if (scanEpoch.get() == ticket) _state.update { it.copy(entries = entries, workspaceEmpty = entries.isEmpty()) } if (graph.layout.isInstalled()) gitLock.withLock { val repos = (listOf(workspace) + (workspace.listFiles()?.toList() ?: emptyList())) .filter { it.isDirectory && !java.nio.file.Files.isSymbolicLink(it.toPath()) && File(it, ".git").isDirectory } @@ -143,17 +246,27 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { } } + /** + * Expand/collapse one directory. The flip is synchronous so the chevron + * answers immediately; children load for that directory alone, so a slow + * or broken folder can no longer block or drop the rest of the tree. + */ fun toggleDir(entry: FileEntry) { - _state.update { it.copy(expanded = if (entry.file.absolutePath in it.expanded) it.expanded - entry.file.absolutePath else it.expanded + entry.file.absolutePath) } - task { - if (File(files.checked(entry.file), ".git").exists()) gitLock.withLock { - project = entry.file - publishProject(entry.file) - migrate(entry.file) - refreshGit() + if (!entry.isDir) return + val path = entry.file.absolutePath + val expanding = path !in _state.value.expanded + _state.update { s -> rebuild(s.copy(expanded = if (expanding) s.expanded + path else s.expanded - path)) } + if (expanding) { + if (path !in _state.value.children) loadChildren(path) + task { + if (File(files.checked(entry.file), ".git").exists()) gitLock.withLock { + project = entry.file + publishProject(entry.file) + migrate(entry.file) + refreshGit() + } } } - refresh() } fun openFile(entry: FileEntry) { @@ -270,13 +383,19 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { drafts.readEditor()?.let { d -> if (d.path == absolute || d.path.startsWith("$absolute/")) drafts.clearEditor() } - _state.update { st -> st.copy( - editing = st.editing?.takeIf { e -> - val p = e.file.absolutePath - p != absolute && !p.startsWith("$absolute/") - }, - expanded = st.expanded.filterNot { it == absolute || it.startsWith("$absolute/") }.toSet(), - ) } + _state.update { st -> + val dropped = { key: String -> key == absolute || key.startsWith("$absolute/") } + rebuild( + st.copy( + editing = st.editing?.takeIf { e -> + val p = e.file.absolutePath + p != absolute && !p.startsWith("$absolute/") + }, + expanded = st.expanded.filterNot(dropped).toSet(), + children = st.children.filterKeys { !dropped(it) }, + ), + ) + } if (project?.let { it.absolutePath == absolute || it.absolutePath.startsWith("$absolute/") } == true) { project = null publishProject(null) @@ -287,16 +406,24 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { /** Rename a workspace file or directory within its parent (F-007). */ fun renameEntry(entry: FileEntry, newName: String) { - val oldPath = entry.file.absolutePath task { val dest = files.rename(entry.file, newName) - val newPath = dest.absolutePath + val oldPath = entry.file.absolutePath // The old editor snapshot (hash) is tied to the old path. drafts.readEditor()?.let { d -> if (d.path == oldPath) drafts.clearEditor() } - _state.update { st -> st.copy( - editing = st.editing?.takeIf { it.file.absolutePath != oldPath }, - expanded = st.expanded.map { if (it == oldPath) newPath else it }.toSet(), - ) } + _state.update { st -> + // Cached listings under the old path prefix hold stale File + // objects: drop the subtree (the trailing refresh reloads the + // affected parents) and let the user expand the new name. + val dropped = { key: String -> key == oldPath || key.startsWith("$oldPath/") } + rebuild( + st.copy( + editing = st.editing?.takeIf { it.file.absolutePath != oldPath }, + expanded = st.expanded.filterNot(dropped).toSet(), + children = st.children.filterKeys { !dropped(it) }, + ), + ) + } if (project?.absolutePath == oldPath) { project = dest publishProject(dest) diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt index 351d53f..416417a 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt @@ -114,6 +114,7 @@ fun SettingsScreen(graph: AppGraph) { LargeFlexibleTopAppBar( title = { Text(stringResource(R.string.tab_settings)) }, subtitle = { Text(stringResource(R.string.settings_subtitle)) }, + navigationIcon = { io.github.oustery.opencodephone.NavMenuButton() }, scrollBehavior = scrollBehavior, ) ScreenBody { diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalScreen.kt index b2014a3..18d9d5f 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalScreen.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalScreen.kt @@ -24,13 +24,16 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp @@ -41,6 +44,7 @@ import io.github.oustery.opencodephone.AppGraph import io.github.oustery.opencodephone.R import io.github.oustery.opencodephone.ScreenBody import io.github.oustery.opencodephone.ScreenHeader +import io.github.oustery.opencodephone.core.designsystem.CodeTypography import io.github.oustery.opencodephone.core.designsystem.OcChipTone import io.github.oustery.opencodephone.core.designsystem.OcEmptyState import io.github.oustery.opencodephone.core.designsystem.OcStatusChip @@ -50,6 +54,7 @@ import io.github.oustery.opencodephone.ui.common.VmFactory fun TerminalScreen(graph: AppGraph) { val vm: TerminalViewModel = viewModel(factory = VmFactory.of { TerminalViewModel(graph) }) val state by vm.state.collectAsStateWithLifecycle() + val clipboard = LocalClipboardManager.current LaunchedEffect(Unit) { vm.start() } @@ -57,6 +62,20 @@ fun TerminalScreen(graph: AppGraph) { ScreenHeader( title = stringResource(R.string.tab_terminal), actions = { + TextButton(onClick = { vm.zoomOut() }) { + Text("A-", style = MaterialTheme.typography.labelLarge) + } + TextButton(onClick = { vm.zoomIn() }) { + Text("A+", style = MaterialTheme.typography.labelLarge) + } + IconButton(onClick = { + clipboard.setText(AnnotatedString(vm.outputText())) + }) { + Icon( + painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.terminal_copy_all), + ) + } IconButton(onClick = { vm.clear() }) { Icon( painterResource(R.drawable.ic_clear_output), @@ -97,7 +116,10 @@ fun TerminalScreen(graph: AppGraph) { SelectionContainer { Text( text = line, - style = io.github.oustery.opencodephone.core.designsystem.CodeTypography.output, + style = CodeTypography.output.copy( + fontSize = CodeTypography.output.fontSize * state.fontScale, + lineHeight = CodeTypography.output.lineHeight * state.fontScale, + ), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -132,6 +154,24 @@ fun TerminalScreen(graph: AppGraph) { } } + if (state.history.isNotEmpty()) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.history) { command -> + // Recall fills the composer for editing instead of + // firing blindly — safer for destructive commands. + SuggestionChip( + onClick = { vm.recall(command) }, + label = { + Text(command, style = MaterialTheme.typography.labelSmall, maxLines = 1) + }, + ) + } + } + } + LazyRow( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -160,7 +200,9 @@ fun TerminalScreen(graph: AppGraph) { singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { vm.send() }), - textStyle = io.github.oustery.opencodephone.core.designsystem.CodeTypography.editor, + textStyle = CodeTypography.editor.copy( + fontSize = CodeTypography.editor.fontSize * state.fontScale, + ), shape = MaterialTheme.shapes.small, ) // Primary action keeps the same filled emphasis as the chat @@ -182,7 +224,11 @@ fun TerminalScreen(graph: AppGraph) { private val QUICK_COMMANDS = listOf( "ls -la", "git status", + "git log --oneline -5", "uname -a", "df -h", + "rg --version", + "python3 --version", + "node --version", "opencode --version", ) diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalViewModel.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalViewModel.kt index cc96a77..786ae30 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalViewModel.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalViewModel.kt @@ -16,6 +16,10 @@ data class TerminalUiState( val output: List = emptyList(), val draft: String = "", val alive: Boolean = false, + /** Recently sent commands, newest first (chips above the composer). */ + val history: List = emptyList(), + /** Terminal font scale (1.0 = stock); zoom persists for the session. */ + val fontScale: Float = 1.0f, ) /** @@ -105,11 +109,23 @@ class TerminalViewModel(private val graph: AppGraph) : ViewModel() { fun updateDraft(value: String) = _state.update { it.copy(draft = value) } + /** Full raw transcript for the copy-all action. */ + fun outputText(): String = _state.value.output.joinToString("\n") + + fun zoomIn() = _state.update { it.copy(fontScale = (it.fontScale + 0.1f).coerceAtMost(1.6f)) } + fun zoomOut() = _state.update { it.copy(fontScale = (it.fontScale - 0.1f).coerceAtLeast(0.7f)) } + + /** Put a recalled history entry into the composer for editing. */ + fun recall(command: String) = _state.update { it.copy(draft = command) } + fun send() { val text = _state.value.draft val proc = process ?: return if (!proc.isAlive || text.isBlank()) return appendLine("$ $text") + // Keep a short de-duplicated history so recent commands stay one tap + // away (and survive tab switches with the retained view model). + _state.update { st -> st.copy(history = (listOf(text) + st.history.filter { it != text }).take(MAX_HISTORY)) } viewModelScope.launch(Dispatchers.IO) { runCatching { proc.outputStream.write((text + "\n").toByteArray(Charsets.UTF_8)) @@ -160,5 +176,6 @@ class TerminalViewModel(private val graph: AppGraph) : ViewModel() { private companion object { const val MAX_LINES = 400 + const val MAX_HISTORY = 24 } } diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 0000000..6aed564 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..6f35ee5 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 271b75e..c5211ca 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -144,6 +144,7 @@ Перезапуск шелла команда шелла… Выполнить + Скопировать весь вывод Сервер, провайдеры и окружение @@ -218,6 +219,10 @@ Сервер OpenCode активен на порту %1$d Ошибка сервера OpenCode: %1$s Сервер OpenCode остановлен + Задача агента выполняется… + Задача агента выполнена + Запущенных задач нет + Открыть меню навигации Запустить / перезапустить сервер Удалённые хосты используют HTTPS с проверкой сертификата. Введите имя хоста или IP, не URL. Для HTTP-сервера используйте защищённый туннель к localhost. Открытие защищённых настроек… @@ -286,6 +291,10 @@ Выберите действие для файла. Выберите действие для папки. Действия с файлом + Показать скрытые файлы + Скрыть скрытые файлы + Папку %1$s не удалось прочитать. Нажмите, чтобы повторить. + %1$d эл. Проект: %1$s В режиме удалённого сервера можно прикреплять только текстовые файлы до %1$d КБ — сервер не может читать файлы с телефона. Для бинарных файлов нужно локальное окружение. Резервная копия настроек diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4ab1b22..8d85ce8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -161,6 +161,10 @@ Choose an action for this file. Choose an action for this folder. File actions + Show hidden files + Hide hidden files + Folder %1$s cannot be read. Tap it to retry. + %1$d items The Linux environment is not installed yet. Finish the setup on the home screen first. @@ -168,6 +172,7 @@ Restart shell shell command… Run + Copy all output Output will appear here Run a command below — use the quick chips or the input line. Shell is not running — tap restart above @@ -245,6 +250,10 @@ OpenCode server active on port %1$d OpenCode server failed: %1$s OpenCode server stopped + Agent task running… + Agent task finished + No running agent task + Open navigation menu Start / restart server Remote hosts use HTTPS with certificate validation. Enter a hostname or IP, not a URL. For an HTTP server, use a secure tunnel to localhost. Opening secure settings… diff --git a/app/src/test/java/io/github/oustery/opencodephone/ui/files/FileTreeTest.kt b/app/src/test/java/io/github/oustery/opencodephone/ui/files/FileTreeTest.kt new file mode 100644 index 0000000..e268013 --- /dev/null +++ b/app/src/test/java/io/github/oustery/opencodephone/ui/files/FileTreeTest.kt @@ -0,0 +1,92 @@ +package io.github.oustery.opencodephone.ui.files + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FileTreeTest { + + private fun dir(path: String) = FileEntry(java.io.File(path), path.substringAfterLast('/'), true, 0) + private fun file(path: String, size: Long = -1) = + FileEntry(java.io.File(path), path.substringAfterLast('/'), false, 0, size) + + @Test + fun `collapsed tree shows only the root children`() { + val children = mapOf( + "/ws" to listOf(dir("/ws/repo"), file("/ws/README.md")), + ) + val rows = FileTree.flatten(children, expanded = emptySet(), root = "/ws") + assertEquals(listOf("/ws/repo", "/ws/README.md"), rows.map { it.file.path }) + assertEquals(listOf(0, 0), rows.map { it.depth }) + } + + @Test + fun `expanded directories emit nested rows with depth`() { + val children = mapOf( + "/ws" to listOf(dir("/ws/repo"), file("/ws/README.md")), + "/ws/repo" to listOf(dir("/ws/repo/src"), file("/ws/repo/app.kt")), + "/ws/repo/src" to listOf(file("/ws/repo/src/Main.kt")), + ) + val rows = FileTree.flatten(children, expanded = setOf("/ws/repo", "/ws/repo/src"), root = "/ws") + assertEquals( + listOf("/ws/repo", "/ws/repo/src", "/ws/repo/src/Main.kt", "/ws/repo/app.kt", "/ws/README.md"), + rows.map { it.file.path }, + ) + assertEquals(listOf(0, 1, 2, 1, 0), rows.map { it.depth }) + } + + @Test + fun `expanded but not loaded directory shows no children yet`() { + val children = mapOf( + "/ws" to listOf(dir("/ws/empty-looking")), + ) + val rows = FileTree.flatten(children, expanded = setOf("/ws/empty-looking"), root = "/ws") + assertEquals(listOf("/ws/empty-looking"), rows.map { it.file.path }) + assertEquals(0, rows[0].depth) + } + + @Test + fun `collapse hides the whole subtree`() { + val children = mapOf( + "/ws" to listOf(dir("/ws/a"), dir("/ws/b")), + "/ws/a" to listOf(file("/ws/a/inner.txt")), + ) + val rows = FileTree.flatten(children, expanded = setOf("/ws/a"), root = "/ws") + assertEquals(listOf("/ws/a", "/ws/a/inner.txt", "/ws/b"), rows.map { it.file.path }) + val collapsed = FileTree.flatten(children, expanded = emptySet(), root = "/ws") + assertEquals(listOf("/ws/a", "/ws/b"), collapsed.map { it.file.path }) + } + + @Test + fun `sorting is directories first then case-insensitive names`() { + val entries = listOf( + file("/ws/zebra.txt"), + dir("/ws/Alpha"), + file("/ws/apple.txt"), + dir("/ws/beta"), + ) + val sorted = FileTree.sortEntries(entries) + assertEquals( + listOf("Alpha", "beta", "apple.txt", "zebra.txt"), + sorted.map { it.name }, + ) + } + + @Test + fun `sizes format for humans`() { + assertEquals("", FileTree.formatSize(-1)) + assertEquals("512 B", FileTree.formatSize(512)) + assertEquals("1.0 KB", FileTree.formatSize(1024)) + assertEquals("1.5 KB", FileTree.formatSize(1536)) + assertEquals("100 KB", FileTree.formatSize(1024 * 100)) + assertEquals("1.0 MB", FileTree.formatSize(1024 * 1024)) + } + + @Test + fun `dotfiles are hidden but not the navigation aliases`() { + assertEquals(true, FileTree.isHiddenName(".git")) + assertEquals(true, FileTree.isHiddenName(".env")) + assertEquals(false, FileTree.isHiddenName("git")) + assertEquals(false, FileTree.isHiddenName(".")) + assertEquals(false, FileTree.isHiddenName("..")) + } +} diff --git a/core/model/src/main/java/io/github/oustery/opencodephone/core/model/AgentActivity.kt b/core/model/src/main/java/io/github/oustery/opencodephone/core/model/AgentActivity.kt new file mode 100644 index 0000000..4f0d24c --- /dev/null +++ b/core/model/src/main/java/io/github/oustery/opencodephone/core/model/AgentActivity.kt @@ -0,0 +1,15 @@ +package io.github.oustery.opencodephone.core.model + +/** + * What the agent is doing right now, as seen by the app shell. The persistent + * foreground notification (EnvService) and the sidebar footer render this so + * a task keeps its status visible while the user is outside the chat screen + * or outside the app entirely. + */ +enum class AgentActivity { + /** No prompt is being processed for the active session. */ + IDLE, + + /** The agent is streaming/retrying a run for the active session. */ + RUNNING, +} diff --git a/core/model/src/main/java/io/github/oustery/opencodephone/core/model/BootstrapModels.kt b/core/model/src/main/java/io/github/oustery/opencodephone/core/model/BootstrapModels.kt index e8fb373..1b4338a 100644 --- a/core/model/src/main/java/io/github/oustery/opencodephone/core/model/BootstrapModels.kt +++ b/core/model/src/main/java/io/github/oustery/opencodephone/core/model/BootstrapModels.kt @@ -76,19 +76,20 @@ object Pins { const val PROOT_VERSION: String = "termux-52d65a4dc140-android2" /** - * Pre-resolved toolset tarballs (git, curl, ripgrep and their dependency - * closure) published as assets of the app release. Downloaded and + * Pre-resolved toolset tarballs (git, curl, ripgrep, less, procps, jq, + * fd-find, tree, zip, unzip, python3 and their dependency closure) + * published as assets of a dedicated toolset release. Downloaded and * extracted app-side so bootstrap never has to run apt/dpkg inside proot * (Android 12+ kills high-CPU child processes — see ADR-008). */ const val TOOLSET_URL_PREFIX: String = - "https://github.com/oustery/opencodephone/releases/download/v0.1.1/toolset-" + "https://github.com/oustery/opencodephone/releases/download/toolset-v1/toolset-" const val TOOLSET_URL_SUFFIX: String = ".tar.gz" /** sha256 of each toolset tarball, keyed by Debian architecture. */ val TOOLSET_SHA256: Map = mapOf( - "arm64" to "98e492bd5469a7591092097a428af77329a5b240a5946e64b940196811a9b706", - "arm" to "34e94b84a0abf29f6e799d257e16bae3d62dc01866f3df601a320d629e3fe5e6", - "amd64" to "565a139ff1bbeabb03a3ffb0a7900c586268d1dd999f88cd2bf25c3f355b79f9", + "arm64" to "d965e982a147854ec1ed22690437d9657bbf501c8668432589b76fc627cbdc14", + "arm" to "e30509886e141bbc2dd05042a3b4d8f065d1b118e03262adf7f4851cb3f22acc", + "amd64" to "7a498476f70314436ed824ecdc84c294e3194d2d1dac556b3fd560afb7374106", ) } diff --git a/tools/build_toolset.py b/tools/build_toolset.py index fb27684..51c090e 100644 --- a/tools/build_toolset.py +++ b/tools/build_toolset.py @@ -27,7 +27,14 @@ BASE = "http://deb.debian.org/debian" SUITE = "trixie" -SEEDS = ["git", "curl", "ripgrep", "less", "procps", "ca-certificates"] +# The pre-resolved toolset installed at bootstrap. Seeds beyond the original +# git/curl/ripgrep core are requested for the on-device experience: jq and +# fd-find (as `fdfind`) for data/file lookup, tree/zip/unzip for archives and +# inspection, python3 for agent-written scripts and quick automation. +SEEDS = [ + "git", "curl", "ripgrep", "less", "procps", "ca-certificates", + "jq", "fd-find", "tree", "zip", "unzip", "python3", +] WORK = os.environ.get("TOOLSET_WORK", os.path.join(os.path.dirname(__file__), ".work")) OUT = os.environ.get("TOOLSET_OUT", os.path.join(os.path.dirname(__file__), ".out"))