Open transcript file links in explorer
This commit is contained in:
1 parent
3c6e6778fd
commit
e3cca97cdd
6 files changed
+129
-12
No files matched your search
+12
@@ -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`
|
||||
|
||||
@@ -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,6 +247,10 @@ 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 {
|
||||
val fileLinkHandler = rememberFileLinkHandler { path ->
|
||||
screen = here.copy(files = here.summary.filesTarget(path))
|
||||
}
|
||||
CompositionLocalProvider(LocalFileLinkHandler provides fileLinkHandler) {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
@@ -254,6 +259,7 @@ fun AppRoot(
|
||||
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 ->
|
||||
|
||||
@@ -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>(Spot.Dir(target.start)) }
|
||||
val initialDirectory =
|
||||
target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start)
|
||||
var here by
|
||||
remember(target) {
|
||||
mutableStateOf<Spot>(
|
||||
target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory
|
||||
)
|
||||
}
|
||||
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
||||
|
||||
@@ -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<TextLayoutResult>() }
|
||||
// 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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user