diff --git a/EXPLORER.md b/EXPLORER.md index e082c8c..0e8036e 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -267,6 +267,18 @@ The speedometer went; the report is a "Copy render timings" row in already are. **Moving it is where the no-coordinate-taps rule got enforced** (Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI". +### 14. File links in a session open in the explorer + +A markdown destination that is an absolute path or a local `file:` URI opens that document in the +session's explorer, on the session's setup. A trailing editor line and optional column are removed; +the viewer opens the file but does not yet scroll to a line. Web links, relative links and `file:` +URIs naming another host keep their ordinary external behaviour. The distinction is deliberately +narrow: a relative link might be a web reference, and the phone must not silently reinterpret it as +a path on another machine. + +The markdown link handler is provided around the session rather than taught about setups. That +keeps the renderer reusable and makes the explorer's existing setup target the one navigation path. + ## HTTP surface In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields` diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index c8efd06..f24223a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -246,14 +247,19 @@ fun AppRoot( // A Box so the explorer can be drawn *over* the session rather than instead of it. // No imePadding here, for the reason above -- the explorer adds its own. Box { - SessionScreen( - settings = current, - summary = here.summary, - onBack = goToMain, - onFiles = { screen = here.copy(files = it) }, - share = share, - onShareTaken = { share = null }, - ) + val fileLinkHandler = rememberFileLinkHandler { path -> + screen = here.copy(files = here.summary.filesTarget(path)) + } + CompositionLocalProvider(LocalFileLinkHandler provides fileLinkHandler) { + SessionScreen( + settings = current, + summary = here.summary, + onBack = goToMain, + onFiles = { screen = here.copy(files = it) }, + share = share, + onShareTaken = { share = null }, + ) + } // Its own back handler is registered after this screen's, so it is the one the // platform asks first, and it steps back inside itself before closing. here.files?.let { target -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index cf74fe2..ba693e8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -48,7 +48,22 @@ import kotlinx.coroutines.withContext * where it was working. That is what makes a second way in -- from the setups tab -- one more * caller rather than any new code here. */ -data class FilesTarget(val setup: String, val setupName: String, val start: String) +data class FilesTarget( + val setup: String, + val setupName: String, + val start: String, + /** A document to open immediately; [start] remains the fallback directory. */ + val file: String? = null, +) + +/** The explorer target for this session's machine, optionally opened on [file]. */ +fun SessionSummary.filesTarget(file: String? = null) = + FilesTarget( + setup = setup, + setupName = setupName, + start = cwd?.takeIf { it.isNotBlank() } ?: "~", + file = file, + ) /** Where the explorer is: in a directory, or in one file. */ private sealed class Spot(val path: String) { @@ -72,7 +87,14 @@ private sealed class Spot(val path: String) { @Composable fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { val scope = rememberCoroutineScope() - var here by remember { mutableStateOf(Spot.Dir(target.start)) } + val initialDirectory = + target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start) + var here by + remember(target) { + mutableStateOf( + target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory + ) + } val listings = remember { mutableStateMapOf>() } var creating by remember { mutableStateOf(false) } // Edit mode and whether anything has been typed live here rather than in the pane below, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt index 417fb86..e2893db 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt @@ -32,6 +32,7 @@ import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.resolveImageAlt import com.mikepenz.markdown.utils.resolveImageLink +import java.net.URI import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode @@ -89,6 +90,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif content.buildMarkdownAnnotatedString(node, style, settings) } val uriHandler = LocalUriHandler.current + val fileLinkHandler = LocalFileLinkHandler.current val onPlainTap = LocalMarkdownTap.current val layout = remember { Ref() } // The renderer's own rule for a style that names no colour: the theme's text colour. @@ -127,7 +129,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif when { url != null -> { up.consume() - uriHandler.openUri(url) + if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url) } onPlainTap != null -> { up.consume() @@ -164,6 +166,55 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif */ val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } +/** + * Opens a markdown destination inside the current session when it names a file on that session's + * machine. Null outside a session, where every link keeps its ordinary URI behaviour. + */ +val LocalFileLinkHandler = compositionLocalOf<((String) -> Boolean)?> { null } + +/** + * A stable markdown link handler whose behaviour follows the latest [onFile]. Keeping its identity + * stable matters: every visible markdown paragraph reads it, and a session recomposes on every + * streamed event. + */ +@Composable +fun rememberFileLinkHandler(onFile: (String) -> Unit): (String) -> Boolean { + val latest = rememberUpdatedState(onFile) + return remember { + { destination -> + val path = filePathOf(destination) + if (path == null) false + else { + latest.value(path) + true + } + } + } +} + +/** + * The path named by a local-file markdown destination. + * + * Only absolute paths and local `file:` URIs are claimed. A relative destination might be a web + * link, and sending one to a machine's filesystem would silently give an ordinary link a different + * meaning. Editors commonly append a line and optional column; the current viewer opens the file + * itself, so those coordinates are removed here. + */ +internal fun filePathOf(destination: String): String? { + val uri = runCatching { URI(destination) }.getOrNull() + val path = + when { + destination.startsWith("/") && !destination.startsWith("//") -> + uri?.path ?: destination.substringBefore('#').substringBefore('?') + uri != null && + uri.scheme.equals("file", ignoreCase = true) && + (uri.host.isNullOrEmpty() || uri.host == "localhost") -> uri.path + else -> null + } + if (path.isNullOrEmpty() || !path.startsWith('/')) return null + return path.replace(Regex(":\\d+(?::\\d+)?$"), "") +} + /** * [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 2d2723b..d144d0e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1411,15 +1411,10 @@ fun SessionScreen( "Files", onClick = { onFiles( - FilesTarget( - setup = summary.setup, - setupName = summary.setupName, - // Where this session works, and the machine's own home when - // it was never given a directory -- resolved there rather - // than guessed at here, since this app does not know that - // home. - start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", - ) + // Where this session works, and the machine's own home when it + // was never given a directory -- resolved there rather than + // guessed at here, since this app does not know that home. + summary.filesTarget() ) }, ) diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/MarkdownLinksTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/MarkdownLinksTest.kt new file mode 100644 index 0000000..3decbbc --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/MarkdownLinksTest.kt @@ -0,0 +1,31 @@ +package com.example.aiapp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MarkdownLinksTest { + @Test + fun `absolute file paths are opened on the session machine`() { + assertEquals( + "/home/bob/repos/ai app/Main.kt", + filePathOf("/home/bob/repos/ai%20app/Main.kt"), + ) + assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt")) + assertEquals("/home/bob/Main.kt", filePathOf("file://localhost/home/bob/Main.kt")) + } + + @Test + fun `editor coordinates select the file itself`() { + assertEquals("/home/bob/Main.kt", filePathOf("/home/bob/Main.kt:42")) + assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt:42:7#L42")) + } + + @Test + fun `ordinary links keep their external meaning`() { + assertNull(filePathOf("https://example.com/source.kt")) + assertNull(filePathOf("docs/source.kt")) + assertNull(filePathOf("//example.com/source.kt")) + assertNull(filePathOf("file://example.com/source.kt")) + } +}