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"))