Open transcript file links in explorer
This commit is contained in:
1 parent
3c6e6778fd
commit
e3cca97cdd
6 files changed
+137
-20
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**
|
already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
||||||
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
|
(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
|
## HTTP surface
|
||||||
|
|
||||||
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
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.
|
// 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.
|
// No imePadding here, for the reason above -- the explorer adds its own.
|
||||||
Box {
|
Box {
|
||||||
SessionScreen(
|
val fileLinkHandler = rememberFileLinkHandler { path ->
|
||||||
settings = current,
|
screen = here.copy(files = here.summary.filesTarget(path))
|
||||||
summary = here.summary,
|
}
|
||||||
onBack = goToMain,
|
CompositionLocalProvider(LocalFileLinkHandler provides fileLinkHandler) {
|
||||||
onFiles = { screen = here.copy(files = it) },
|
SessionScreen(
|
||||||
share = share,
|
settings = current,
|
||||||
onShareTaken = { share = null },
|
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
|
// 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.
|
// platform asks first, and it steps back inside itself before closing.
|
||||||
here.files?.let { target ->
|
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
|
* 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.
|
* 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. */
|
/** Where the explorer is: in a directory, or in one file. */
|
||||||
private sealed class Spot(val path: String) {
|
private sealed class Spot(val path: String) {
|
||||||
@@ -72,7 +87,14 @@ private sealed class Spot(val path: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
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>>() }
|
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||||
var creating by remember { mutableStateOf(false) }
|
var creating by remember { mutableStateOf(false) }
|
||||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
// 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.getUnescapedTextInNode
|
||||||
import com.mikepenz.markdown.utils.resolveImageAlt
|
import com.mikepenz.markdown.utils.resolveImageAlt
|
||||||
import com.mikepenz.markdown.utils.resolveImageLink
|
import com.mikepenz.markdown.utils.resolveImageLink
|
||||||
|
import java.net.URI
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
import org.intellij.markdown.MarkdownElementTypes
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
import org.intellij.markdown.MarkdownTokenTypes
|
||||||
import org.intellij.markdown.ast.ASTNode
|
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)
|
content.buildMarkdownAnnotatedString(node, style, settings)
|
||||||
}
|
}
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val fileLinkHandler = LocalFileLinkHandler.current
|
||||||
val onPlainTap = LocalMarkdownTap.current
|
val onPlainTap = LocalMarkdownTap.current
|
||||||
val layout = remember { Ref<TextLayoutResult>() }
|
val layout = remember { Ref<TextLayoutResult>() }
|
||||||
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
// 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 {
|
when {
|
||||||
url != null -> {
|
url != null -> {
|
||||||
up.consume()
|
up.consume()
|
||||||
uriHandler.openUri(url)
|
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url)
|
||||||
}
|
}
|
||||||
onPlainTap != null -> {
|
onPlainTap != null -> {
|
||||||
up.consume()
|
up.consume()
|
||||||
@@ -164,6 +166,55 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
|||||||
*/
|
*/
|
||||||
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
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
|
* [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
|
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
|
||||||
|
|||||||
@@ -1411,15 +1411,10 @@ fun SessionScreen(
|
|||||||
"Files",
|
"Files",
|
||||||
onClick = {
|
onClick = {
|
||||||
onFiles(
|
onFiles(
|
||||||
FilesTarget(
|
// Where this session works, and the machine's own home when it
|
||||||
setup = summary.setup,
|
// was never given a directory -- resolved there rather than
|
||||||
setupName = summary.setupName,
|
// guessed at here, since this app does not know that home.
|
||||||
// Where this session works, and the machine's own home when
|
summary.filesTarget()
|
||||||
// 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() } ?: "~",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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