From fbc471139e6628404cedc323f9245cdcd9fe763f Mon Sep 17 00:00:00 2001
From: Oustery <162016530+oustery@users.noreply.github.com>
Date: Wed, 11 Nov 2026 08:52:53 +0000
Subject: [PATCH 1/4] feat(app): sidebar navigation, lazy file tree, task
notifications, terminal and toolset expansion
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four user-reported pain points, one batch:
1. Files explorer rebuilt around a lazily loaded tree. The old design
rescanned the whole workspace on every folder tap (a global scan whose
first bad entry or lost race silently killed expansion), and the chevron
never moved. Now every directory loads its own single level on expand
(FileTree.flatten, pure + JVM-tested), the chevron answers the tap
synchronously with an animated rotation, loading dirs show a spinner,
per-directory errors mark only that row (tap to retry), files gain
human-readable sizes and loaded dirs a child count, and dotfiles like
.git stay hidden behind a header toggle.
2. Background resilience + task board. The shell/agent work now drives the
persistent notification: a shared AgentActivity state (fed by the chat
busy flag) renders 'running…/finished' with an indeterminate progress
bar, a tap opens the app, and a bounded (30 min) partial wake lock keeps
a streaming run alive with the screen off. A running task also keeps the
foreground service up in remote-only mode, where it used to be stopped.
3. Terminal conveniences: de-duplicated command history chips (recall fills
the composer instead of firing blindly), copy-all-output header action,
A-/A+ font zoom for output and composer, and more quick commands.
4. Sidebar navigation: the bottom bar is replaced by a Material 3 modal
drawer (menu button in every screen header, NavigationDrawerItem
destinations, live server + agent status chips), the wide-layout rail
stays permanent. ScreenHeader gains NavMenuButton, Settings uses
navigationIcon on LargeFlexibleTopAppBar.
5. Toolset expansion: bootstrap now ships jq, fd-find (fdfind), tree, zip,
unzip and python3 on top of git/curl/ripgrep; tarballs rebuilt with
tools/build_toolset.py and re-pinned (toolset-v1 release assets).
No new dependencies; all strings localized (en+ru); UI policy green.
---
.gitignore | 2 +
app/src/main/AndroidManifest.xml | 1 +
.../github/oustery/opencodephone/AppRoot.kt | 170 ++++++++++++--
.../oustery/opencodephone/EnvService.kt | 105 ++++++++-
.../oustery/opencodephone/OpenCodePhoneApp.kt | 11 +
.../opencodephone/ui/chat/ChatViewModel.kt | 13 ++
.../opencodephone/ui/files/FileTree.kt | 82 +++++++
.../opencodephone/ui/files/FilesScreen.kt | 74 ++++--
.../opencodephone/ui/files/FilesViewModel.kt | 211 ++++++++++++++----
.../ui/settings/SettingsScreen.kt | 1 +
.../ui/terminal/TerminalScreen.kt | 50 ++++-
.../ui/terminal/TerminalViewModel.kt | 17 ++
app/src/main/res/drawable/ic_eye.xml | 10 +
app/src/main/res/drawable/ic_menu.xml | 10 +
app/src/main/res/values-ru/strings.xml | 9 +
app/src/main/res/values/strings.xml | 9 +
.../opencodephone/ui/files/FileTreeTest.kt | 92 ++++++++
.../opencodephone/core/model/AgentActivity.kt | 15 ++
.../core/model/BootstrapModels.kt | 13 +-
tools/build_toolset.py | 9 +-
20 files changed, 807 insertions(+), 97 deletions(-)
create mode 100644 app/src/main/java/io/github/oustery/opencodephone/ui/files/FileTree.kt
create mode 100644 app/src/main/res/drawable/ic_eye.xml
create mode 100644 app/src/main/res/drawable/ic_menu.xml
create mode 100644 app/src/test/java/io/github/oustery/opencodephone/ui/files/FileTreeTest.kt
create mode 100644 core/model/src/main/java/io/github/oustery/opencodephone/core/model/AgentActivity.kt
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..6e99e9e
--- /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.toDouble()
+ 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..0dbc8d6 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
@@ -10,16 +10,26 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
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 +47,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 +59,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 +206,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 +244,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 +381,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 +404,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..5dc6e47
--- /dev/null
+++ b/app/src/main/res/drawable/ic_eye.xml
@@ -0,0 +1,10 @@
+
+
+
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..42329cf
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu.xml
@@ -0,0 +1,10 @@
+
+
+
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"))
From 564894230216dc66c92bee18cf2cf96deb73b5ff Mon Sep 17 00:00:00 2001
From: Oustery <162016530+oustery@users.noreply.github.com>
Date: Wed, 11 Nov 2026 09:12:05 +0000
Subject: [PATCH 2/4] fix(app): drawables must not reference the missing
colorControlNormal attr
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The app theme has no colorControlNormal (no AppCompat bridge); a tint
attribute pointing at it breaks resource linking. Both icons now follow
the Lucide stroke style every other drawable uses — Icon() applies the
tone at composition time anyway.
---
app/src/main/res/drawable/ic_eye.xml | 14 ++++++--------
app/src/main/res/drawable/ic_menu.xml | 15 +++++++--------
2 files changed, 13 insertions(+), 16 deletions(-)
diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_eye.xml
index 5dc6e47..6aed564 100644
--- a/app/src/main/res/drawable/ic_eye.xml
+++ b/app/src/main/res/drawable/ic_eye.xml
@@ -1,10 +1,8 @@
+
+
-
+ android:width="24dp" android:height="24dp"
+ android:viewportWidth="24" android:viewportHeight="24">
+
+
diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml
index 42329cf..6f35ee5 100644
--- a/app/src/main/res/drawable/ic_menu.xml
+++ b/app/src/main/res/drawable/ic_menu.xml
@@ -1,10 +1,9 @@
+
+
-
+ android:width="24dp" android:height="24dp"
+ android:viewportWidth="24" android:viewportHeight="24">
+
+
+
From 12ddb0e5451a2a0ec941e693ccb203cbb8c4a274 Mon Sep 17 00:00:00 2001
From: Oustery <162016530+oustery@users.noreply.github.com>
Date: Wed, 11 Nov 2026 09:19:08 +0000
Subject: [PATCH 3/4] fix(app): missing imports in the lazy files view model
AtomicLong and the app R class were referenced without imports; the
third reported error was a cascade of the first.
---
.../io/github/oustery/opencodephone/ui/files/FilesViewModel.kt | 2 ++
1 file changed, 2 insertions(+)
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 0dbc8d6..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.*
@@ -10,6 +11,7 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
+import java.util.concurrent.atomic.AtomicLong
data class GitChange(val status: String, val path: String)
From 1af973494ea2d3bc10a48208fd91333d2e7b982b Mon Sep 17 00:00:00 2001
From: Oustery <162016530+oustery@users.noreply.github.com>
Date: Wed, 11 Nov 2026 09:26:17 +0000
Subject: [PATCH 4/4] fix(app): formatSize promoted units one step too early
The divisor loop divided 102400 B into 100 but then labelled it MB:
the value after the first division is in KB, so the unit index must
start there. 102400 B now renders as 100 KB.
---
.../java/io/github/oustery/opencodephone/ui/files/FileTree.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
index 6e99e9e..7638783 100644
--- 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
@@ -67,7 +67,7 @@ object FileTree {
if (bytes < 0) return ""
if (bytes < 1024) return "$bytes B"
val units = listOf("KB", "MB", "GB", "TB")
- var value = bytes.toDouble()
+ var value = bytes / 1024.0
var unit = 0
while (value >= 1024.0 && unit < units.lastIndex) {
value /= 1024.0