@@ -203,6 +203,9 @@ fun FileViewScreen(
203203 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
204204 // the choice, and applied to the off-thread match scan below.
205205 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
206+ // Regex mode: when on, the query is interpreted as a regular expression (Java Pattern)
207+ // instead of a plain substring. A power-user affordance mirroring editor find bars.
208+ var regex by rememberSaveable(path) { mutableStateOf(false) }
206209 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
207210 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
208211 var debouncedFind by remember { mutableStateOf("") }
@@ -210,12 +213,28 @@ fun FileViewScreen(
210213 val q = findQuery.trim()
211214 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
212215 }
213− val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
216+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive, regex) {
214217 val q = debouncedFind
215218 value = if (q.isEmpty()) emptyList()
216219 else withContext(Dispatchers.Default) {
220+ // Compile the pattern once per scan when regex mode is on; a malformed pattern
221+ // yields no matches (rather than throwing) and the FindBar surfaces the error
222+ // state via the zero match count. Case-sensitivity maps to Pattern flags.
223+ val pattern = if (regex) {
224+ runCatching {
225+ val flags = if (caseSensitive) 0 else java.util.regex.Pattern.CASE_INSENSITIVE
226+ java.util.regex.Pattern.compile(q, flags)
227+ }.getOrNull()
228+ } else {
229+ null
230+ }
217231 lines.mapIndexedNotNull { index, line ->
218− index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
232+ val matched = if (regex) {
233+ pattern?.matcher(line)?.find() == true
234+ } else {
235+ line.contains(q, ignoreCase = !caseSensitive)
236+ }
237+ if (matched) index else null
219238 }
220239 }
221240 }
@@ -280,6 +299,14 @@ fun FileViewScreen(
280299 val ok = runCatchingCancellable {
281300 withContext(Dispatchers.IO) {
282301 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
302+ // Evict stale cache files handed to external apps so repeated
303+ // opens of large files don't accumulate forever. A TTL (vs.
304+ // wiping the dir) preserves files an external app may still be
305+ // reading from a recent open. Pruned before writing the new one.
306+ val cutoff = System.currentTimeMillis() - NetworkConfig.externalCacheTtlMs
307+ dir.listFiles()?.forEach { f ->
308+ runCatching { if (f.lastModified() < cutoff) f.delete() }
309+ }
283310 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
284311 file.writeText(rawText)
285312 val authority = context.packageName + ".fileprovider"
@@ -365,6 +392,8 @@ fun FileViewScreen(
365392 onRetry = { vm.reload() },
366393 caseSensitive = caseSensitive,
367394 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
395+ regex = regex,
396+ onToggleRegex = { regex = !regex },
368397 onShareFull = {
369398 val send = Intent(Intent.ACTION_SEND).apply {
370399 type = "text/plain"
@@ -658,6 +687,8 @@ private fun BoxScope.FileViewStateContent(
658687 onShareFull: () -> Unit,
659688 caseSensitive: Boolean,
660689 onToggleCaseSensitive: () -> Unit,
690+ regex: Boolean,
691+ onToggleRegex: () -> Unit,
661692) {
662693 when {
663694 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
@@ -767,6 +798,8 @@ private fun BoxScope.FileViewStateContent(
767798 onShareFull = onShareFull,
768799 caseSensitive = caseSensitive,
769800 onToggleCaseSensitive = onToggleCaseSensitive,
801+ regex = regex,
802+ onToggleRegex = onToggleRegex,
770803 )
771804 }
772805}
@@ -796,6 +829,8 @@ private fun BoxScope.FileViewContentBody(
796829 onShareFull: () -> Unit,
797830 caseSensitive: Boolean,
798831 onToggleCaseSensitive: () -> Unit,
832+ regex: Boolean,
833+ onToggleRegex: () -> Unit,
799834) {
800835 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
801836 // reachable while scrolling. Each contributes a top inset so the content below
@@ -856,6 +891,8 @@ private fun BoxScope.FileViewContentBody(
856891 onClose = onCloseFind,
857892 caseSensitive = caseSensitive,
858893 onToggleCaseSensitive = onToggleCaseSensitive,
894+ regex = regex,
895+ onToggleRegex = onToggleRegex,
859896 )
860897 }
861898 }
@@ -908,9 +945,12 @@ private fun FindBar(
908945 onClose: () -> Unit,
909946 caseSensitive: Boolean,
910947 onToggleCaseSensitive: () -> Unit,
948+ regex: Boolean,
949+ onToggleRegex: () -> Unit,
911950) {
912951 val keyboardController = LocalSoftwareKeyboardController.current
913952 val matchCaseLabel = stringResource(R.string.match_case)
953+ val regexLabel = stringResource(R.string.regex_search)
914954 Row(
915955 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
916956 verticalAlignment = Alignment.CenterVertically,
@@ -934,6 +974,15 @@ private fun FindBar(
934974 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
935975 )
936976 Spacer(Modifier.size(8.dp))
977+ // Regex toggle (".*"): when on, the query is a Java Pattern. Mirrors
978+ // editor find bars; the label stays compact to match the "Aa" chip.
979+ FilterChip(
980+ selected = regex,
981+ onClick = onToggleRegex,
982+ label = { Text(".*", style = MaterialTheme.typography.labelSmall) },
983+ modifier = Modifier.semantics { contentDescription = regexLabel },
984+ )
985+ Spacer(Modifier.size(8.dp))
937986 val countText = if (query.isBlank()) ""
938987 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
939988 else stringResource(R.string.match_count, matchPos + 1, matchCount)